fix(sync): 强化 S-05 上传持久性

This commit is contained in:
2026-09-09 12:53:43 +08:00
parent 0890cabf3b
commit 0c82f1f78e
7 changed files with 901 additions and 49 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Sync Protocol v1
状态:服务端协议原型已实现,生产运维与桌面客户端尚未验收。入口为 `server sync/sync_server`;运行时 `/openapi.json` 是字段约束来源。不能将 SQLite Fixture 结果称为 PostgreSQL / MinIO 验收。
状态:服务端协议已实现,PostgreSQL 17、MinIO 与双 Worker 的 S-04 生产并发验收已通过;其余服务运维门和发布端到端验收仍在推进。入口为 `server sync/sync_server`;运行时 `/openapi.json` 是字段约束来源。SQLite Fixture 只用于协议回归,不能代替生产依赖验收。
## Rust 上传客户端增量
@@ -40,7 +40,7 @@ Workspace schema 4 增加持久 inbox、分页 boundary 和冲突记录,并为
| GET `/vaults/{id}/history/{file_id}` | before/limit 倒序历史 |
| GET `/vaults/{id}/objects/{hash}` | 经鉴权读取与摘要复核 |
上传分块最大 1 MiB,对象最大 100 MiB。offset 冲突为 409 `UPLOAD_OFFSET`,响应给出已确认偏移;客户端查询偏移后续传。完成请求响应丢失时重新预申请同一摘要即可确认对象已存在。未完成上传不允许提交 Revision。
上传分块最大 1 MiB,对象最大 100 MiB。offset 冲突为 409 `UPLOAD_OFFSET`,响应给出已确认偏移;客户端查询偏移后续传。暂存文件长于数据库 offset 时先截断并 `fsync`,短于 offset 或无法读取时删除损坏上传并释放预留,再返回 409 `UPLOAD_DAMAGED``restart_required=true`。完成请求响应丢失时用同一 upload ID 重试,持久回执返回原摘要且不会重复计费;重新预申请同一摘要也会确认对象已存在。未完成上传不允许提交 Revision。
提交字段:operation_id、file_id、base_revision、path、operation (`put`/`delete`)、content_hash、size。device_id 从会话取得,客户端不能冒充。移动使用同一 file_id、新 path 及当前对象;恢复历史使用历史摘要、新 operation_id 和当前 base_revision,生成新 Revision。删除必须携带当前基线、空摘要和 size=0。
@@ -50,7 +50,7 @@ Vault 行锁内执行幂等键验证、CAS、路径检查、序列分配及历
## 保留与未交付边界
当前历史和 tombstone **永久保留**,游标不主动过期;不启用 GC,以免缺少恢复演练时删除历史对象。配额包含全部历史对象未完成上传预留额度。通知尚未实现,客户端必须主动按 cursor 拉取。上传过期清理、对象 GC、限速指标、完整备份恢复工具与生产负载验收仍未交付。`/ready` 目前只检查数据库 schema,不证明 S3 就绪
当前历史和 tombstone **永久保留**,游标不主动过期;不启用对象 GC,以免缺少恢复演练时删除历史对象。配额包含全部历史对象未完成上传预留额度。每个 Worker 每 60 秒清理已过期上传;清理与 PUT/complete 使用相同 Vault 行锁顺序,先删除暂存文件再删除预留记录,失败可在下一轮重试。通知尚未实现,客户端必须主动按 cursor 拉取。对象 GC、限速指标、完整备份恢复工具与生产负载验收仍未交付。`/ready` 会在受控缓存周期内检查数据库 schema、暂存目录写入/`fsync`/读回及 S3 临时对象写入/读回/删除,任一依赖异常返回 503
验证向量见 `server sync/tests/test_protocol.py`:双设备 CAS、幂等重放、移动与删除、历史恢复、稳定分页、隔离、撤销、刷新、配额、路径冲突、摘要与断点。
@@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py `
报告目录包含 `summary.json``case-manifest.json``junit.xml``cases/<ID>.json` 和脱敏的 `logs/<ID>.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04 Sync 服务 driver 已登记;其余 20 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
当前 runner 与失败闭合行为已实现,A-02/A-03 Sidecar、B-01/B-02 凭据、D-01 扩展包、S-01/S-02/S-03/S-08 Sync 客户端以及 S-04/S-05 Sync 服务 driver 已登记;其余 19 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。
@@ -0,0 +1,438 @@
"""S-05 upload durability and cleanup races on the production stack."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import socket
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from urllib.parse import urlsplit
from sync_production_stack import ROOT, SERVICE, SyncProductionStack, call, sha256, stop_tree
ROUNDS = 100
def result(case_id: str, status: str, reason: str, facts: dict) -> dict:
passed = status == "PASSED"
assertions = [
(
"the oracle uses real PostgreSQL, MinIO, and both production workers",
facts.get("dependencies") is True and facts.get("worker_count") == 2,
),
(
"100 same-offset races never acknowledge more than durable staging bytes",
facts.get("offset_races") == ROUNDS and facts.get("durable_offsets") == ROUNDS,
),
(
"100 disk-ahead and 100 disk-behind uploads reconcile or restart safely",
facts.get("disk_ahead") == ROUNDS and facts.get("disk_behind") == ROUNDS,
),
(
"100 lost complete responses recover from durable receipts",
facts.get("response_loss_recoveries") == ROUNDS,
),
(
"complete retries charge every unique object exactly once",
facts.get("quota_exact") is True and facts.get("receipt_count_exact") is True,
),
(
"100 expiry cleanup races release every reservation within 15 minutes",
facts.get("cleanup_races") == ROUNDS
and facts.get("cleanup_split") == {"complete": 50, "cleanup": 50}
and facts.get("remaining_uploads") == 0
and facts.get("reserved_bytes") == 0
and facts.get("max_cleanup_latency_ms", 900001) < 900000,
),
(
"all referenced objects remain readable and cleaned uploads are not referenced",
facts.get("referenced_objects_verified") == facts.get("object_count")
and facts.get("cleaned_objects_absent") == 50
and facts.get("staging_files") == 0,
),
]
evidence = "real PostgreSQL 17, MinIO S3, two Uvicorn workers, shared staging, and black-box HTTP"
return {
"schema": 1,
"case_id": case_id,
"status": status,
"reason": reason,
"assertions": [
{
"name": name,
"status": "PASSED" if passed and actual else "FAILED",
"evidence": evidence,
}
for name, actual in assertions
],
"metrics": {
"offset_races": facts.get("offset_races", 0),
"response_loss_recoveries": facts.get("response_loss_recoveries", 0),
"cleanup_races": facts.get("cleanup_races", 0),
"max_cleanup_latency_ms": facts.get("max_cleanup_latency_ms", 0),
"worker_count": facts.get("worker_count", 0),
},
"files": [
{"path": relative, "sha256": sha256(ROOT / relative)}
for relative in (
"server sync/sync_server/app.py",
"server sync/sync_server/database.py",
"server sync/sync_server/maintenance.py",
"server sync/sync_server/storage.py",
"server sync/tests/test_production_storage.py",
"scripts/acceptance_cases/sync_production_stack.py",
"scripts/acceptance_cases/s05_sync_uploads.py",
)
],
"revisions": [
{
"scope": "runtime",
"postgres": facts.get("postgres_version"),
"minio": facts.get("minio_version"),
},
{
"scope": "upload fault matrix",
"same_offset": facts.get("offset_races", 0),
"disk_ahead": facts.get("disk_ahead", 0),
"disk_behind": facts.get("disk_behind", 0),
"response_loss": facts.get("response_loss_recoveries", 0),
"cleanup_races": facts.get("cleanup_races", 0),
},
{
"scope": "cleanup outcomes",
**facts.get("cleanup_split", {}),
"max_latency_ms": facts.get("max_cleanup_latency_ms", 0),
},
{
"scope": "final integrity",
"objects": facts.get("object_count", 0),
"used_bytes": facts.get("used_bytes", 0),
"reserved_bytes": facts.get("reserved_bytes", -1),
},
],
}
def content(vector: str, index: int) -> bytes:
return f"OpenNexus-S05-{vector}-{index:03d}".encode()
def begin(base: str, auth: dict, data: bytes):
digest = hashlib.sha256(data).hexdigest()
code, info, _ = call(
"POST", base + "/uploads", headers=auth, body={"content_hash": digest, "size": len(data)}
)
assert code == 200 and info == {
"complete": False,
"upload_id": info["upload_id"],
"offset": 0,
}
return digest, info["upload_id"], base + "/uploads/" + info["upload_id"]
def complete(path: str, auth: dict, digest: str) -> None:
code, body, _ = call("POST", path + "/complete", headers=auth)
assert code == 200 and body == {"complete": True, "content_hash": digest}
def drop_complete_response(url: str, authorization: str) -> None:
parsed = urlsplit(url)
target = parsed.path + (("?" + parsed.query) if parsed.query else "")
request = (
f"POST {target} HTTP/1.1\r\n"
f"Host: {parsed.hostname}:{parsed.port}\r\n"
f"Authorization: {authorization}\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n\r\n"
).encode("ascii")
with socket.create_connection((parsed.hostname, parsed.port), timeout=10) as stream:
stream.sendall(request)
stream.shutdown(socket.SHUT_WR)
# The request is complete, but the client deliberately never reads its response.
time.sleep(0.01)
class CleanupClient:
def __init__(self, stack: SyncProductionStack):
program = (
"import json,os,sys,time; from pathlib import Path; "
"from sync_server.database import Database; "
"from sync_server.maintenance import cleanup_expired_uploads; "
"d=Database(os.environ['SYNC_DATABASE_URL']); s=Path(os.environ['SYNC_STAGING_DIR']); "
"\nfor line in sys.stdin:\n"
" r=json.loads(line); time.sleep(r['delay_ms']/1000); "
" print(json.dumps(cleanup_expired_uploads(d,s,now=r['now'],limit=500)),flush=True)"
)
self.process = subprocess.Popen(
[str(stack.server_python), "-u", "-c", program],
cwd=SERVICE,
env=stack.service_env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stack.handles[2],
text=True,
bufsize=1,
)
self.lock = threading.Lock()
def cleanup(self, *, now: int, delay_ms: int) -> dict:
with self.lock:
if self.process.poll() is not None or self.process.stdin is None or self.process.stdout is None:
raise RuntimeError("CLEANUP_HELPER_EXITED")
self.process.stdin.write(json.dumps({"now": now, "delay_ms": delay_ms}) + "\n")
self.process.stdin.flush()
line = self.process.stdout.readline()
if not line:
raise RuntimeError("CLEANUP_HELPER_NO_RESPONSE")
return json.loads(line)
def close(self) -> None:
if self.process.stdin is not None:
self.process.stdin.close()
try:
self.process.wait(timeout=10)
except subprocess.TimeoutExpired:
stop_tree(self.process)
if self.process.stdout is not None:
self.process.stdout.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
facts: dict = {}
reason = ""
status = "FAILED"
stack = cleanup_client = None
try:
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
stack = SyncProductionStack(
config, Path(os.environ["OPENNEXUS_ACCEPTANCE_DATA_ROOT"]), "s05"
).start()
facts.update(
{
"dependencies": True,
"postgres_version": stack.postgres_version,
"minio_version": stack.minio_version,
"worker_count": len(stack.worker_ids()),
}
)
assert facts["worker_count"] == 2
auth, base, vault_id = stack.create_vault("S-05 isolated")
committed: dict[str, bytes] = {}
cleaned: dict[str, bytes] = {}
expected_used = 0
offset_races = durable_offsets = 0
for index in range(ROUNDS):
data = content("offset", index)
digest, upload_id, path = begin(base, auth, data)
with ThreadPoolExecutor(max_workers=2) as pool:
attempts = list(
pool.map(
lambda _: call("PUT", path + "?offset=0", headers=auth, body=data),
range(2),
)
)
codes = sorted(item[0] for item in attempts)
rejected = next(item for item in attempts if item[0] == 409)
assert codes == [200, 409]
assert rejected[1]["error"] == {
"code": "UPLOAD_OFFSET",
"details": {"offset": len(data)},
}
state = call("GET", path, headers=auth)
assert state[0] == 200 and state[1]["offset"] == len(data)
assert (stack.staging / upload_id).stat().st_size == state[1]["offset"]
complete(path, auth, digest)
committed[digest] = data
expected_used += len(data)
offset_races += 1
durable_offsets += 1
facts.update({"offset_races": offset_races, "durable_offsets": durable_offsets})
disk_ahead = 0
for index in range(ROUNDS):
data = content("ahead", index)
digest, upload_id, path = begin(base, auth, data)
local = stack.staging / upload_id
local.write_bytes(data + b"-uncommitted-tail")
state = call("GET", path, headers=auth)
assert state[0] == 200 and state[1]["offset"] == 0 and local.stat().st_size == 0
assert call("PUT", path + "?offset=0", headers=auth, body=data)[1] == {
"offset": len(data)
}
complete(path, auth, digest)
committed[digest] = data
expected_used += len(data)
disk_ahead += 1
facts["disk_ahead"] = disk_ahead
disk_behind = 0
for index in range(ROUNDS):
data = content("behind", index)
digest, upload_id, path = begin(base, auth, data)
assert call("PUT", path + "?offset=0", headers=auth, body=data[:1])[1] == {
"offset": 1
}
local = stack.staging / upload_id
local.write_bytes(b"")
damaged = call("GET", path, headers=auth)
assert damaged[0] == 409 and damaged[1]["error"] == {
"code": "UPLOAD_DAMAGED",
"details": {"restart_required": True},
}
assert not local.exists()
expired = call("GET", path, headers=auth)
assert expired[0] == 404 and expired[1]["error"]["code"] == "UPLOAD_EXPIRED"
replacement_digest, replacement_id, replacement = begin(base, auth, data)
assert replacement_digest == digest and replacement_id != upload_id
assert call("PUT", replacement + "?offset=0", headers=auth, body=data)[1] == {
"offset": len(data)
}
complete(replacement, auth, digest)
committed[digest] = data
expected_used += len(data)
disk_behind += 1
facts["disk_behind"] = disk_behind
response_loss_recoveries = 0
for index in range(ROUNDS):
data = content("lost-complete", index)
digest, upload_id, path = begin(base, auth, data)
assert call("PUT", path + "?offset=0", headers=auth, body=data)[1] == {
"offset": len(data)
}
drop_complete_response(path + "/complete", auth["Authorization"])
deadline = time.monotonic() + 10
while (stack.staging / upload_id).exists() and time.monotonic() < deadline:
time.sleep(0.01)
assert not (stack.staging / upload_id).exists()
complete(path, auth, digest)
complete(path, auth, digest)
committed[digest] = data
expected_used += len(data)
response_loss_recoveries += 1
facts["response_loss_recoveries"] = response_loss_recoveries
cleanup_client = CleanupClient(stack)
cleanup_complete = cleanup_removed = 0
cleanup_latencies = []
for index in range(ROUNDS):
data = content("cleanup", index)
digest, upload_id, path = begin(base, auth, data)
assert call("PUT", path + "?offset=0", headers=auth, body=data)[1] == {
"offset": len(data)
}
barrier = threading.Barrier(2)
complete_delay = 0 if index % 2 == 0 else 250
cleanup_delay = 250 if index % 2 == 0 else 0
def race_complete():
barrier.wait(timeout=5)
time.sleep(complete_delay / 1000)
return call("POST", path + "/complete", headers=auth)
def race_cleanup():
barrier.wait(timeout=5)
started = time.monotonic()
outcome = cleanup_client.cleanup(
now=int(time.time()) + 7200, delay_ms=cleanup_delay
)
return outcome, int((time.monotonic() - started) * 1000)
with ThreadPoolExecutor(max_workers=2) as pool:
complete_future = pool.submit(race_complete)
cleanup_future = pool.submit(race_cleanup)
complete_result = complete_future.result(timeout=30)
cleanup_result, latency = cleanup_future.result(timeout=30)
cleanup_latencies.append(latency)
if index % 2 == 0:
assert complete_result[0] == 200 and cleanup_result["expired_uploads_removed"] == 0
cleanup_complete += 1
committed[digest] = data
expected_used += len(data)
else:
assert complete_result[0] == 404
assert complete_result[1]["error"]["code"] == "UPLOAD_EXPIRED"
assert cleanup_result["expired_uploads_removed"] == 1
cleanup_removed += 1
cleaned[digest] = data
assert not (stack.staging / upload_id).exists()
cleanup_client.close()
cleanup_client = None
facts.update(
{
"cleanup_races": ROUNDS,
"cleanup_split": {"complete": cleanup_complete, "cleanup": cleanup_removed},
"max_cleanup_latency_ms": max(cleanup_latencies),
}
)
vaults = call("GET", stack.origin + "/sync/v1/vaults", headers=auth)[1]["items"]
current = next(item for item in vaults if item["id"] == vault_id)
facts["used_bytes"] = current["used"]
facts["quota_exact"] = current["used"] == expected_used
facts["remaining_uploads"] = stack.sql_scalar(
f"SELECT COUNT(*) FROM uploads WHERE vault_id='{vault_id}'"
)
facts["reserved_bytes"] = stack.sql_scalar(
f"SELECT COALESCE(SUM(size),0) FROM uploads WHERE vault_id='{vault_id}'"
)
facts["object_count"] = stack.sql_scalar(
f"SELECT COUNT(*) FROM objects WHERE vault_id='{vault_id}'"
)
receipt_count = stack.sql_scalar(
f"SELECT COUNT(*) FROM upload_receipts WHERE vault_id='{vault_id}'"
)
facts["receipt_count_exact"] = receipt_count == len(committed)
assert facts["object_count"] == len(committed)
verified = 0
for digest, data in committed.items():
response = call("GET", base + "/objects/" + digest, headers=auth)
assert response[0] == 200 and response[1] == data
verified += 1
absent = 0
for digest in cleaned:
response = call("GET", base + "/objects/" + digest, headers=auth)
assert response[0] == 404 and response[1]["error"]["code"] == "OBJECT_NOT_FOUND"
absent += 1
facts["referenced_objects_verified"] = verified
facts["cleaned_objects_absent"] = absent
facts["staging_files"] = len(list(stack.staging.iterdir()))
stack.assert_running()
assert len(stack.worker_ids()) == 2
assert facts["quota_exact"] and facts["receipt_count_exact"]
assert facts["remaining_uploads"] == facts["reserved_bytes"] == facts["staging_files"] == 0
status = "PASSED"
except BaseException as error:
reason = "S05_ORACLE_FAILED:" + type(error).__name__
finally:
if cleanup_client is not None:
cleanup_client.close()
if stack is not None:
stack.stop()
payload = result(
case_id,
status if case_id == "S-05" else "FAILED",
reason or ("" if case_id == "S-05" else "CASE_ID_MISMATCH"),
facts,
)
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0 if payload["status"] == "PASSED" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,351 @@
"""Isolated PostgreSQL, MinIO, and multi-worker Sync acceptance stack."""
from __future__ import annotations
import hashlib
import json
import os
import secrets
import socket
import subprocess
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[2]
SERVICE = ROOT / "server sync"
MINIO_RELEASE = "RELEASE.2025-09-07T16-13-09Z"
MINIO_SHA256 = "af709e6ba68488404e85acdd22a3030d0f5e56a108d4b27d744f18ceb50861b4"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def free_port() -> int:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
def call(method: str, url: str, *, headers=None, body=None, timeout=30):
request_headers = dict(headers or {})
payload = body
if isinstance(body, (dict, list)):
payload = json.dumps(body, separators=(",", ":")).encode()
request_headers["Content-Type"] = "application/json"
request = Request(url, data=payload, headers=request_headers, method=method)
try:
response = urlopen(request, timeout=timeout)
except HTTPError as error:
response = error
data = response.read()
media_type = response.headers.get("Content-Type", "")
value = json.loads(data) if data and "json" in media_type else data
return response.status, value, {key.lower(): item for key, item in response.headers.items()}
def wait_http(url: str, *, expected=200, timeout=30) -> None:
until = time.monotonic() + timeout
while time.monotonic() < until:
try:
if call("GET", url)[0] == expected:
return
except (OSError, URLError):
pass
time.sleep(0.1)
raise RuntimeError("SERVICE_START_TIMEOUT")
def stop_tree(process: subprocess.Popen | None) -> None:
if process is None or process.poll() is not None:
return
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
else:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
class SyncProductionStack:
"""Own a disposable production dependency stack for one acceptance case."""
def __init__(self, config: dict, data_root: Path, case_tag: str):
self.config = config
self.data_root = data_root
self.case_tag = case_tag.lower()
self.postgres_started = False
self.minio = None
self.sync = None
self.handles = []
initdb = Path(config["artifacts"]["postgres_initdb"]).resolve()
self.pg_bin = initdb.parent
executable = lambda name: self.pg_bin / (name + ".exe" if os.name == "nt" else name)
self.initdb = initdb
self.pg_ctl = executable("pg_ctl")
self.createdb = executable("createdb")
self.postgres = executable("postgres")
self.psql = executable("psql")
self.minio_server = Path(config["artifacts"]["minio_server"]).resolve()
self.server_python = SERVICE / (".venv/Scripts/python.exe" if os.name == "nt" else ".venv/bin/python")
required = (
self.initdb,
self.pg_ctl,
self.createdb,
self.postgres,
self.psql,
self.minio_server,
self.server_python,
)
if not all(path.is_file() for path in required):
raise RuntimeError("PRODUCTION_RUNTIME_MISSING")
self.postgres_version = subprocess.check_output(
[str(self.postgres), "--version"], text=True, timeout=10
).strip()
self.minio_version = subprocess.check_output(
[str(self.minio_server), "--version"], text=True, timeout=10
).splitlines()[0]
if " 17." not in self.postgres_version or MINIO_RELEASE not in self.minio_version:
raise RuntimeError("PRODUCTION_RUNTIME_VERSION_MISMATCH")
if sha256(self.minio_server) != MINIO_SHA256:
raise RuntimeError("MINIO_ARTIFACT_INTEGRITY")
self.stack = data_root / f"{self.case_tag}-production-stack"
self.postgres_data = self.stack / "postgres"
self.minio_data = self.stack / "objects"
self.staging = self.stack / "staging"
self.pg_port, self.minio_port, self.console_port, self.sync_port = (
free_port() for _ in range(4)
)
self.database_url = f"postgresql+psycopg://postgres@127.0.0.1:{self.pg_port}/opennexus"
self.origin = f"http://127.0.0.1:{self.sync_port}"
self.username = self.case_tag + "-" + secrets.token_hex(8)
self.password = secrets.token_urlsafe(32)
self.minio_user = self.case_tag + secrets.token_hex(8)
self.minio_password = secrets.token_urlsafe(32)
self.bucket = "opennexus-" + self.case_tag
self.service_env = {}
def start(self) -> "SyncProductionStack":
self.stack.mkdir()
self.minio_data.mkdir()
self.staging.mkdir()
pg_log = (self.stack / "postgres.log").open("wb")
minio_log = (self.stack / "minio.log").open("wb")
sync_log = (self.stack / "sync.log").open("wb")
self.handles.extend([pg_log, minio_log, sync_log])
subprocess.run(
[
str(self.initdb),
"-D",
str(self.postgres_data),
"-U",
"postgres",
"-A",
"trust",
"--no-locale",
"-E",
"UTF8",
],
stdout=pg_log,
stderr=subprocess.STDOUT,
check=True,
timeout=120,
)
subprocess.run(
[
str(self.pg_ctl),
"-D",
str(self.postgres_data),
"-l",
str(self.stack / "postgres-server.log"),
"-o",
f"-p {self.pg_port} -h 127.0.0.1",
"-w",
"start",
],
stdout=pg_log,
stderr=subprocess.STDOUT,
check=True,
timeout=60,
)
self.postgres_started = True
subprocess.run(
[
str(self.createdb),
"-h",
"127.0.0.1",
"-p",
str(self.pg_port),
"-U",
"postgres",
"opennexus",
],
stdout=pg_log,
stderr=subprocess.STDOUT,
check=True,
timeout=30,
)
minio_env = os.environ.copy()
minio_env.update(
{"MINIO_ROOT_USER": self.minio_user, "MINIO_ROOT_PASSWORD": self.minio_password}
)
self.minio = subprocess.Popen(
[
str(self.minio_server),
"server",
str(self.minio_data),
"--address",
f"127.0.0.1:{self.minio_port}",
"--console-address",
f"127.0.0.1:{self.console_port}",
],
stdout=minio_log,
stderr=subprocess.STDOUT,
env=minio_env,
)
wait_http(f"http://127.0.0.1:{self.minio_port}/minio/health/ready")
self.service_env = os.environ.copy()
self.service_env.update(
{
"SYNC_DATABASE_URL": self.database_url,
"SYNC_S3_ENDPOINT": f"http://127.0.0.1:{self.minio_port}",
"SYNC_S3_BUCKET": self.bucket,
"SYNC_STAGING_DIR": str(self.staging),
"SYNC_HOST": "127.0.0.1",
"SYNC_PORT": str(self.sync_port),
"AWS_ACCESS_KEY_ID": self.minio_user,
"AWS_SECRET_ACCESS_KEY": self.minio_password,
"AWS_DEFAULT_REGION": "us-east-1",
"ACCEPTANCE_USERNAME": self.username,
"ACCEPTANCE_PASSWORD": self.password,
}
)
subprocess.run(
[
str(self.server_python),
"-c",
"import os,boto3; from sync_server.database import Database; "
"d=Database(os.environ['SYNC_DATABASE_URL']); d.migrate(); "
"d.add_user(os.environ['ACCEPTANCE_USERNAME'],os.environ['ACCEPTANCE_PASSWORD']); "
"boto3.client('s3',endpoint_url=os.environ['SYNC_S3_ENDPOINT']).create_bucket(Bucket=os.environ['SYNC_S3_BUCKET'])",
],
cwd=SERVICE,
env=self.service_env,
stdout=sync_log,
stderr=subprocess.STDOUT,
check=True,
timeout=60,
)
self.sync = subprocess.Popen(
[str(self.server_python), "-m", "sync_server", "serve", "--workers", "2"],
cwd=SERVICE,
env=self.service_env,
stdout=sync_log,
stderr=subprocess.STDOUT,
)
wait_http(self.origin + "/ready", timeout=60)
return self
def create_vault(self, label: str):
code, session, _ = call(
"POST",
self.origin + "/sync/v1/auth/sessions",
body={
"username": self.username,
"password": self.password,
"device_name": label,
},
)
if code != 200:
raise RuntimeError("ACCEPTANCE_LOGIN_FAILED")
auth = {"Authorization": "Bearer " + session["access_token"]}
code, vault, _ = call(
"POST", self.origin + "/sync/v1/vaults", headers=auth, body={"name": label}
)
if code != 200:
raise RuntimeError("ACCEPTANCE_VAULT_FAILED")
return auth, self.origin + "/sync/v1/vaults/" + vault["vault_id"], vault["vault_id"]
def sql_scalar(self, statement: str) -> int:
output = subprocess.check_output(
[
str(self.psql),
"-X",
"-A",
"-t",
"-v",
"ON_ERROR_STOP=1",
"-h",
"127.0.0.1",
"-p",
str(self.pg_port),
"-U",
"postgres",
"-d",
"opennexus",
"-c",
statement,
],
text=True,
timeout=30,
).strip()
return int(output)
def worker_ids(self, attempts=200) -> set[str]:
from concurrent.futures import ThreadPoolExecutor
def probe(_):
return call(
"GET", self.origin + "/health", headers={"Connection": "close"}
)[2].get("x-opennexus-worker")
with ThreadPoolExecutor(max_workers=40) as pool:
return {value for value in pool.map(probe, range(attempts)) if value}
def assert_running(self) -> None:
if self.sync is None or self.minio is None:
raise RuntimeError("PRODUCTION_STACK_NOT_STARTED")
if self.sync.poll() is not None or self.minio.poll() is not None:
raise RuntimeError("PRODUCTION_STACK_EXITED")
def stop(self) -> None:
stop_tree(self.sync)
stop_tree(self.minio)
if self.postgres_started:
subprocess.run(
[
str(self.pg_ctl),
"-D",
str(self.postgres_data),
"-m",
"fast",
"-w",
"stop",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=30,
)
for handle in self.handles:
handle.close()
+12
View File
@@ -83,6 +83,18 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
"required_metrics": ("successful_commits", "conflict_responses", "worker_count"),
"required_artifacts": ("postgres_initdb", "minio_server"),
},
"S-05": {
"driver": "scripts/acceptance_cases/s05_sync_uploads.py",
"timeout_seconds": 900,
"required_metrics": (
"offset_races",
"response_loss_recoveries",
"cleanup_races",
"max_cleanup_latency_ms",
"worker_count",
),
"required_artifacts": ("postgres_initdb", "minio_server"),
},
}
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}")
RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")
+84 -43
View File
@@ -27,6 +27,15 @@ class SyncError(Exception):
self.status, self.code, self.details = status, code, details or {}
class StagingDamaged(Exception):
"""Internal signal used to commit cleanup before returning UPLOAD_DAMAGED."""
def __init__(self, upload):
self.upload_id = upload["id"]
self.vault_id = upload["vault_id"]
self.device_id = upload["device_id"]
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
@@ -232,22 +241,48 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
try:
length = path.stat().st_size
if length < upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True})
raise StagingDamaged(upload)
if length > upload["offset_bytes"]:
with path.open("r+b") as stream:
stream.truncate(upload["offset_bytes"])
stream.flush()
os.fsync(stream.fileno())
except OSError:
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True}) from None
raise StagingDamaged(upload) from None
return path
def discard_damaged_upload(damaged):
# Re-open after the failed operation released its transaction. Deleting
# inside the failed transaction would be rolled back with the response.
with db.transaction() as conn:
suffix = " FOR UPDATE" if not db.sqlite else ""
row(conn, "SELECT id FROM vaults WHERE id=:v" + suffix, v=damaged.vault_id)
upload = row(
conn,
"SELECT id FROM uploads WHERE id=:id AND vault_id=:v AND device_id=:device",
id=damaged.upload_id,
v=damaged.vault_id,
device=damaged.device_id,
)
if upload:
# File first: interruption leaves a row whose quota reservation
# can still be released by expiry maintenance.
(staging / upload["id"]).unlink(missing_ok=True)
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload["id"])
def upload_damaged(damaged):
discard_damaged_upload(damaged)
raise SyncError(409, "UPLOAD_DAMAGED", {"restart_required": True})
@app.get("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
def upload_status(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]}
try:
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
return {"offset": upload["offset_bytes"], "size": upload["size"], "expires": upload["expires"]}
except StagingDamaged as damaged:
upload_damaged(damaged)
@app.put("/sync/v1/vaults/{vault_id}/uploads/{upload_id}")
async def upload_chunk(vault_id: str, upload_id: str, request: Request,
@@ -263,23 +298,26 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
authorization, offset, data)
def persist_upload_chunk(vault_id, upload_id, authorization, offset, data):
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
if offset != upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]})
if offset + len(data) > upload["size"]:
raise SyncError(413, "OBJECT_TOO_LARGE")
# 先刷盘后提交 offset;崩溃重试覆盖未确认尾部,不能重复追加。
import os
with (staging / upload_id).open("r+b") as stream:
stream.seek(offset)
stream.write(data)
stream.truncate()
stream.flush()
os.fsync(stream.fileno())
run(conn, "UPDATE uploads SET offset_bytes=:offset WHERE id=:id", id=upload_id, offset=offset + len(data))
return {"offset": offset + len(data)}
try:
with db.transaction() as conn:
upload = authorized_upload(conn, vault_id, upload_id, authorization)
reconcile_staging(upload)
if offset != upload["offset_bytes"]:
raise SyncError(409, "UPLOAD_OFFSET", {"offset": upload["offset_bytes"]})
if offset + len(data) > upload["size"]:
raise SyncError(413, "OBJECT_TOO_LARGE")
# 先刷盘后提交 offset;崩溃重试覆盖未确认尾部,不能重复追加。
import os
with (staging / upload_id).open("r+b") as stream:
stream.seek(offset)
stream.write(data)
stream.truncate()
stream.flush()
os.fsync(stream.fileno())
run(conn, "UPDATE uploads SET offset_bytes=:offset WHERE id=:id", id=upload_id, offset=offset + len(data))
return {"offset": offset + len(data)}
except StagingDamaged as damaged:
upload_damaged(damaged)
@app.delete("/sync/v1/vaults/{vault_id}/uploads/{upload_id}", status_code=204)
def cancel_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")):
@@ -290,26 +328,29 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
@app.post("/sync/v1/vaults/{vault_id}/uploads/{upload_id}/complete")
def complete_upload(vault_id: str, upload_id: str, authorization: str = Header(default="")):
with db.transaction() as conn:
session, _ = vault(conn, vault_id, authorization, lock=True)
receipt = row(conn, "SELECT hash FROM upload_receipts WHERE id=:id AND vault_id=:v AND device_id=:d",
id=upload_id, v=vault_id, d=session["device_id"])
if receipt:
return {"complete": True, "content_hash": receipt["hash"]}
upload = authorized_upload(conn, vault_id, upload_id, authorization)
path = reconcile_staging(upload)
with path.open("rb") as stream:
content_hash = hashlib.file_digest(stream, "sha256").hexdigest()
if upload["size"] != upload["offset_bytes"] or content_hash != upload["hash"]:
raise SyncError(422, "OBJECT_INTEGRITY")
exists = row(conn, "SELECT hash FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=upload["hash"])
if not exists:
objects.put_file(vault_id + "/" + upload["hash"], path, upload["hash"])
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=upload["size"], now=int(clock()))
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=upload["size"], v=vault_id)
run(conn, "INSERT INTO upload_receipts VALUES (:id,:v,:d,:h,:now)", id=upload_id,
v=vault_id, d=session["device_id"], h=upload["hash"], now=int(clock()))
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload_id)
try:
with db.transaction() as conn:
session, _ = vault(conn, vault_id, authorization, lock=True)
receipt = row(conn, "SELECT hash FROM upload_receipts WHERE id=:id AND vault_id=:v AND device_id=:d",
id=upload_id, v=vault_id, d=session["device_id"])
if receipt:
return {"complete": True, "content_hash": receipt["hash"]}
upload = authorized_upload(conn, vault_id, upload_id, authorization)
path = reconcile_staging(upload)
with path.open("rb") as stream:
content_hash = hashlib.file_digest(stream, "sha256").hexdigest()
if upload["size"] != upload["offset_bytes"] or content_hash != upload["hash"]:
raise SyncError(422, "OBJECT_INTEGRITY")
exists = row(conn, "SELECT hash FROM objects WHERE vault_id=:v AND hash=:h", v=vault_id, h=upload["hash"])
if not exists:
objects.put_file(vault_id + "/" + upload["hash"], path, upload["hash"])
run(conn, "INSERT INTO objects VALUES (:v,:h,:size,:now)", v=vault_id, h=upload["hash"], size=upload["size"], now=int(clock()))
run(conn, "UPDATE vaults SET used=used+:size WHERE id=:v", size=upload["size"], v=vault_id)
run(conn, "INSERT INTO upload_receipts VALUES (:id,:v,:d,:h,:now)", id=upload_id,
v=vault_id, d=session["device_id"], h=upload["hash"], now=int(clock()))
run(conn, "DELETE FROM uploads WHERE id=:id", id=upload_id)
except StagingDamaged as damaged:
upload_damaged(damaged)
(staging / upload_id).unlink(missing_ok=True)
return {"complete": True, "content_hash": upload["hash"]}
+12 -2
View File
@@ -23,7 +23,7 @@ def env(tmp_path):
def test_offset_reconciliation_never_acknowledges_missing_disk_bytes(env):
client, _, _, staging, _ = env
client, db, _, staging, _ = env
auth, _, base = setup(client)
info = client.post(base + "/uploads", headers=auth,
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3}).json()
@@ -35,7 +35,17 @@ def test_offset_reconciliation_never_acknowledges_missing_disk_bytes(env):
assert local.read_bytes() == b"a"
local.write_bytes(b"")
assert client.get(path, headers=auth).json()["error"]["code"] == "UPLOAD_DAMAGED"
assert client.put(path + "?offset=1", headers=auth, content=b"bc").status_code == 409
assert not local.exists()
assert client.put(path + "?offset=1", headers=auth, content=b"bc").status_code == 404
with db.transaction() as conn:
assert row(conn, "SELECT COUNT(*) AS n FROM uploads")["n"] == 0
replacement = client.post(
base + "/uploads",
headers=auth,
json={"content_hash": hashlib.sha256(b"abc").hexdigest(), "size": 3},
).json()
assert replacement["complete"] is False
assert replacement["upload_id"] != info["upload_id"]
def test_complete_retry_has_durable_receipt_and_charges_once(env):