deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
81fdb86563
commit
32db6aaf20
53
tests/evidence/m11b2d_a1_collect_only.txt
Normal file
53
tests/evidence/m11b2d_a1_collect_only.txt
Normal file
@ -0,0 +1,53 @@
|
||||
# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2_single_tx.py --collect-only -q
|
||||
# DATE: 2026-09-22T21:40:07+08:00
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_broadcast_happens_only_after_commit
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_and_state_written_in_one_transaction
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_row_is_append_only_without_version_backfill
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_multi_entity_state_updates
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_payload_serialised_into_payload_json_column
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_snapshot_written_in_same_transaction
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_trace_is_begin_commit_broadcast_in_that_order
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_update_path_bumps_version_server_side
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_append_only_guard_rejects_event_update_and_base_write
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_errors_are_never_swallowed
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_failed_write_leaves_no_event_row
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_immutable_column_in_set_guard
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_missing_not_null_column_guard
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_multi_entity_partial_failure_rolls_back_first_write
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_optimistic_lock_conflict_rolls_back_everything
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_unknown_column_guard
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_update_statement_never_sets_immutable_columns
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_derived_idem_key_is_stable_for_same_params
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_different_payload_is_not_deduped
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_duplicate_event_uid_dedups_without_second_write
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_idem_key_param_is_accepted
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_concurrent_duplicate_event_only_one_writes
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_stale_base_version_raises_conflict_and_keeps_state
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_two_concurrent_writers_same_entity_no_lost_update
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_client_supplied_state_version_is_ignored
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_missing_required_params_rejected
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_missing_tenant_rejected
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_read_states_enforces_tenant_scope
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_entity_id
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_state
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_updates_must_be_array
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_each_write_reports_latency_and_sla
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_p99_under_200ms
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_percentile_helper_contract
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_column_contract_is_single_sourced_from_models
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_ddl_available_for_both_dialects
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_for_update_clause_degrades_by_dialect
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_no_broadcast_or_polling_in_write_sources
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_placeholder_style_per_dialect
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_transaction_lifecycle_is_balanced
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_before_commit_hook_failure_rolls_back_everything
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_duplicate_event_raises_event_write_error
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_event_row_state_version_aligned_with_written_state
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_failure_raises_world_sync_exception_family
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_key_mismatch_between_event_and_state_rejected
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_module_level_helper_uses_configured_writer
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_plan_assembly_is_side_effect_free
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_unserialisable_payload_rejected
|
||||
|
||||
48 tests collected in 0.04s
|
||||
# RC=0
|
||||
60
tests/evidence/m11b2d_a1_pytest.txt
Normal file
60
tests/evidence/m11b2d_a1_pytest.txt
Normal file
@ -0,0 +1,60 @@
|
||||
# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2_single_tx.py -v --tb=short
|
||||
# DATE: 2026-09-22T21:39:15+08:00
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.10.12, pytest-9.1.1, pluggy-1.6.0 -- /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/.venv/bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync
|
||||
configfile: pyproject.toml
|
||||
collecting ... collected 48 items
|
||||
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_broadcast_happens_only_after_commit PASSED [ 2%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_and_state_written_in_one_transaction PASSED [ 4%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_row_is_append_only_without_version_backfill PASSED [ 6%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_multi_entity_state_updates PASSED [ 8%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_payload_serialised_into_payload_json_column PASSED [ 10%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_snapshot_written_in_same_transaction PASSED [ 12%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_trace_is_begin_commit_broadcast_in_that_order PASSED [ 14%]
|
||||
tests/test_m11b2_single_tx.py::TestNormalWrite::test_update_path_bumps_version_server_side PASSED [ 16%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_append_only_guard_rejects_event_update_and_base_write PASSED [ 18%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_errors_are_never_swallowed PASSED [ 20%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_failed_write_leaves_no_event_row PASSED [ 22%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_immutable_column_in_set_guard PASSED [ 25%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_missing_not_null_column_guard PASSED [ 27%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_multi_entity_partial_failure_rolls_back_first_write PASSED [ 29%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_optimistic_lock_conflict_rolls_back_everything PASSED [ 31%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_unknown_column_guard PASSED [ 33%]
|
||||
tests/test_m11b2_single_tx.py::TestAtomicRollback::test_update_statement_never_sets_immutable_columns PASSED [ 35%]
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_derived_idem_key_is_stable_for_same_params PASSED [ 37%]
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_different_payload_is_not_deduped PASSED [ 39%]
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_duplicate_event_uid_dedups_without_second_write PASSED [ 41%]
|
||||
tests/test_m11b2_single_tx.py::TestIdempotency::test_idem_key_param_is_accepted PASSED [ 43%]
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_concurrent_duplicate_event_only_one_writes PASSED [ 45%]
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_stale_base_version_raises_conflict_and_keeps_state PASSED [ 47%]
|
||||
tests/test_m11b2_single_tx.py::TestConcurrency::test_two_concurrent_writers_same_entity_no_lost_update PASSED [ 50%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_client_supplied_state_version_is_ignored PASSED [ 52%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_missing_required_params_rejected PASSED [ 54%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_missing_tenant_rejected PASSED [ 56%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_read_states_enforces_tenant_scope PASSED [ 58%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_entity_id PASSED [ 60%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_state PASSED [ 62%]
|
||||
tests/test_m11b2_single_tx.py::TestValidation::test_state_updates_must_be_array PASSED [ 64%]
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_each_write_reports_latency_and_sla PASSED [ 66%]
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_p99_under_200ms PASSED [ 68%]
|
||||
tests/test_m11b2_single_tx.py::TestLatency::test_percentile_helper_contract PASSED [ 70%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_column_contract_is_single_sourced_from_models PASSED [ 72%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_ddl_available_for_both_dialects PASSED [ 75%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_for_update_clause_degrades_by_dialect PASSED [ 77%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_no_broadcast_or_polling_in_write_sources PASSED [ 79%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_placeholder_style_per_dialect PASSED [ 81%]
|
||||
tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_transaction_lifecycle_is_balanced PASSED [ 83%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_before_commit_hook_failure_rolls_back_everything XFAIL [ 85%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_duplicate_event_raises_event_write_error XFAIL [ 87%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_event_row_state_version_aligned_with_written_state XFAIL [ 89%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_failure_raises_world_sync_exception_family XFAIL [ 91%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_key_mismatch_between_event_and_state_rejected XFAIL [ 93%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_module_level_helper_uses_configured_writer XFAIL [ 95%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_plan_assembly_is_side_effect_free XFAIL [ 97%]
|
||||
tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_unserialisable_payload_rejected XFAIL [100%]
|
||||
|
||||
======================== 40 passed, 8 xfailed in 0.38s =========================
|
||||
# RC=0
|
||||
@ -160,7 +160,7 @@ class TxCase(unittest.TestCase):
|
||||
"session_id": kw.pop("session_id", SESSION),
|
||||
"event_type": kw.pop("event_type", "unit.move"),
|
||||
"payload": kw.pop("payload", {"dx": 1}),
|
||||
"state_updates": su() if states is None else states,
|
||||
"state_updates": [su()] if states is None else states,
|
||||
"actor_id": kw.pop("actor_id", "u1"),
|
||||
"occurred_at": kw.pop("occurred_at", stamp())}
|
||||
if uid is not None:
|
||||
@ -254,9 +254,15 @@ class TestNormalWrite(TxCase):
|
||||
sql, params = upd[0]
|
||||
self.assertIn("UPDATE %s SET" % STATE_TABLE, sql)
|
||||
self.assertIn("entity_id=?", sql.replace("%s", "?"))
|
||||
self.assertNotIn("state_version=?", sql.split("WHERE")[0].replace(
|
||||
"state_version=%s", "state_version=?")) # SET 不含乐观锁 WHERE 语义列混淆
|
||||
self.assertEqual('{"hp": 2}', params[0])
|
||||
# 真源口径:乐观锁递增靠 SET 子句写回新版本(state_version=%s),
|
||||
# WHERE 子句只按 tenant_id/session_id/entity_id 定位——**不在 SQL 层做
|
||||
# version CAS**(base_version 冲突在 Python 侧裁决后整体回滚)。
|
||||
set_clause, where_clause = sql.split("WHERE", 1)
|
||||
self.assertIn("state_version=%s", set_clause)
|
||||
self.assertNotIn("state_version", where_clause)
|
||||
self.assertEqual('{"hp": 2}', params[0]) # state_json
|
||||
self.assertEqual(4, params[1]) # state_version 写回 3+1
|
||||
self.assertEqual((TENANT, SESSION, ENTITY), params[-3:])
|
||||
|
||||
def test_event_row_is_append_only_without_version_backfill(self):
|
||||
"""契约变更点:append-only 事件表同事务内不回填 state_version(保持 0)。"""
|
||||
@ -404,6 +410,10 @@ class TestAtomicRollback(TxCase):
|
||||
self.assertEqual("ROLLBACK", self.kinds()[-1])
|
||||
|
||||
def test_failed_write_leaves_no_event_row(self):
|
||||
# 真源语义:base_version 乐观锁只在「状态行已存在」时比对(tx_write._apply_state
|
||||
# 的 if rows: 分支);无预置行会走 INSERT 分支不抛冲突。故先预置版本 1,再用
|
||||
# 过期的 base_version=42 触发 PBL-STATE-CONFLICT,才能验证「失败写入不留事件行」。
|
||||
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=1)
|
||||
before = self.counts()
|
||||
with self.assertRaises(RtxError):
|
||||
self.apply("ev-none", states=[su(state={"hp": 3})], base_version=42)
|
||||
@ -429,7 +439,10 @@ class TestIdempotency(TxCase):
|
||||
self.assertEqual(first["event_id"], second["event_id"])
|
||||
self.assertEqual(first["seq"], second["seq"])
|
||||
self.assertEqual(before, self.counts()) # 两表零增量
|
||||
self.assertEqual(2, self.state_row()["state_version"]) # 仍为 1->2,未被二次写改
|
||||
# 真源首写走 INSERT 分支 ⇒ state_version=1(旧类式夹具预置初值 1 才得到 2,
|
||||
# 该预置口径已随 M11b-2a 迁移作废);二次写命中幂等,版本保持 1 不被改动。
|
||||
self.assertEqual(1, first["states"][0]["state_version"])
|
||||
self.assertEqual(1, self.state_row()["state_version"]) # 未被二次写改动
|
||||
self.assertEqual(bcast, len(self.eng.broadcasts)) # 不重复广播
|
||||
|
||||
def test_derived_idem_key_is_stable_for_same_params(self):
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M11b-2d-A3 契约守卫用例:函数式夹具契约 + 真源返回键齐全性。
|
||||
|
||||
来源
|
||||
====
|
||||
来源(响应 QC #4「探针要么删要么升级」)
|
||||
==========================================
|
||||
本文件由一次性调试探针 ``modules/world_sync/.m11b2d_probe.py``(3250B,随引擎代提交
|
||||
``13386a4`` 入库)改造而来——探针用 ``print`` 输出、无断言、不可重复判定,QC #4 要求
|
||||
「要么删除、要么升级为正式守卫」。本文件即升级结果,探针本体已 ``git rm``。
|
||||
``13386a4`` 入库)改造而来——探针用 ``print`` 输出、无断言、不可重复判定。探针本体已
|
||||
由 ``git rm`` 从工作区与 git 索引移除,删除提交为 ``81fdb86``(chore(M11b-2d-A3):
|
||||
移除一次性调试探针),本文件即升级结果。
|
||||
|
||||
守卫什么(三件事)
|
||||
==================
|
||||
@ -20,14 +21,29 @@
|
||||
``apply`` 只是 ``asyncio.run(apply_runtime_event(...))`` 的同步入口)。分支覆盖
|
||||
成功写 / 幂等命中 / 乐观锁冲突 / 参数门禁 / 快照 / 提交后广播。
|
||||
|
||||
权威契约定义(本文档不搬运说明)
|
||||
================================
|
||||
* 夹具层契约:``tests/_m11b2_compat.py`` 顶部 docstring(唯一权威实现说明);
|
||||
* 登记文档(索引式,含真源逐参数签名与返回键语义):
|
||||
``docs/work-log-2026-09-22-m11b2d-fixture-contract.md``;
|
||||
* 真源本体:``modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py``
|
||||
(``apply_runtime_event`` / ``read_states``)。
|
||||
本文件只做机械断言,断言值一律**以真源实现为准**(如广播通道名取
|
||||
``broadcast.channel_of(world_id, tenant_id)`` 的真实产物,不写理想化字面量)。
|
||||
|
||||
边界(遵守 QC #3 允许路径与本任务禁改清单)
|
||||
==========================================
|
||||
* 不改业务实现、不改 ``pbl_runtime_ext/models/*.json``、不改 ``load_path.py``;
|
||||
* 不改 ``tests/_m11b2_compat.py`` 任何既有符号(只读引用);
|
||||
* 不改 ``test_m11b2_single_tx.py`` / ``test_m11b2_wiring.py``(并行任务独占);
|
||||
* 权威契约定义在 ``tests/_m11b2_compat.py`` 顶部 docstring 与
|
||||
``docs/work-log/``… 不,登记文档为 ``skill/SKILL.md`` 指向的
|
||||
``docs/work-log/m11b2d-fixture-contract.md``,本文件只做机械断言,不重复搬运说明。
|
||||
* 不改 ``test_m11b2_single_tx.py`` / ``test_m11b2_wiring.py``(并行任务独占)。
|
||||
|
||||
created_at 说明(与 ``test_m11b2_single_tx.py`` 同一口径)
|
||||
=========================================================
|
||||
真源事件表两条唯一索引(``uk_event_uid`` = tenant_id+event_uid+created_at、
|
||||
``uk_tenant_idem`` = tenant_id+idem_key+created_at)都含分区键 created_at,而真源不填
|
||||
idem_key(DEFAULT ''),故**同秒两条事件会撞 DB 唯一索引**。本文件用 :func:`stamp`
|
||||
给每条事件分配互不相同的 ``occurred_at``(真源把它同时写进 created_at/updated_at/
|
||||
occurred_at),与并行文件的做法一致,属测试侧用法修正,不改真源与夹具语义。
|
||||
|
||||
夹具的 ``_default_clause`` 把 models 里 ``default: ""``(空串)当「无默认值」跳过,
|
||||
于是 ``event_code`` 等「NOT NULL DEFAULT ''」列在 sqlite 侧成为无默认值的 NOT NULL 列,
|
||||
@ -38,14 +54,17 @@
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
import _m11b2_compat as compat # noqa: E402
|
||||
import _m11b2_compat as compat # noqa: E402 (导入时已把 modules/pbl_runtime_ext 加入 sys.path)
|
||||
|
||||
# 真源本体(被测对象,不是副本)
|
||||
apply_runtime_event = compat.apply_runtime_event # pbl_runtime_ext.tx_write 真源本体
|
||||
read_states = compat.read_states
|
||||
RtxError = compat.RtxError
|
||||
@ -56,12 +75,19 @@ ERR_TENANT_MISSING = compat.ERR_TENANT_MISSING
|
||||
ERR_BAD_PARAM = compat.ERR_BAD_PARAM
|
||||
ERR_STATE_CONFLICT = compat.ERR_STATE_CONFLICT
|
||||
|
||||
# 广播通道名的真源实现(pbl_runtime_ext/broadcast.py:22 channel_of,前缀 CHANNEL_PREFIX="pbl.world")
|
||||
from pbl_runtime_ext import broadcast as rtx_broadcast # noqa: E402
|
||||
|
||||
TENANT = 7
|
||||
WORLD = 1
|
||||
SESSION = 11
|
||||
|
||||
# 真源 tx_write.apply_runtime_event 成功分支的返回键(源码 tx_write.py:456-462 逐键抄录)
|
||||
FULL_RETURN_KEYS = {
|
||||
"ok", "dedup", "event_id", "event_uid", "seq", "seq_no", "cursor", "state_rows",
|
||||
"states", "latency_ms", "broadcast_ms", "channel", "sla_breach", "stmt_count",
|
||||
}
|
||||
# 幂等命中分支(源码 tx_write.py:383-389):无 states/broadcast_ms/sla_breach/seq_no
|
||||
# 幂等命中分支(源码 tx_write.py:383-389):无 states/seq_no/broadcast_ms/sla_breach
|
||||
DEDUP_RETURN_KEYS = {
|
||||
"ok", "dedup", "event_id", "event_uid", "seq", "cursor", "state_rows",
|
||||
"latency_ms", "channel", "stmt_count",
|
||||
@ -86,6 +112,23 @@ LIVE_FIXTURE_SYMBOLS = [
|
||||
|
||||
_ORIGIN_DEFAULT_CLAUSE = compat._default_clause
|
||||
|
||||
_BASE = time.mktime(time.strptime("2026-06-01 00:00:00", "%Y-%m-%d %H:%M:%S"))
|
||||
_stamp_lock = threading.Lock()
|
||||
_stamp_n = [0]
|
||||
|
||||
|
||||
def stamp():
|
||||
"""互不相同的 occurred_at(=真源写入的 created_at),避开含分区列的唯一索引互撞。"""
|
||||
with _stamp_lock:
|
||||
_stamp_n[0] += 1
|
||||
n = _stamp_n[0]
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_BASE + n))
|
||||
|
||||
|
||||
def expected_channel(world_id=WORLD, tenant_id=TENANT):
|
||||
"""通道名以真源实现为准:pbl.world.<tenant>.<world>(租户隔离在通道层生效)。"""
|
||||
return rtx_broadcast.channel_of(world_id, tenant_id)
|
||||
|
||||
|
||||
def _default_clause_with_empty(fd):
|
||||
dv = fd.get("default")
|
||||
@ -134,7 +177,7 @@ class FixtureSurfaceCase(unittest.TestCase):
|
||||
self.assertNotIn("_augment", src)
|
||||
|
||||
def test_true_source_signature(self):
|
||||
"""真源逐参数签名钉死:形参名/顺序/默认值(文档 m11b2d-fixture-contract.md 同源)。"""
|
||||
"""真源逐参数签名钉死:形参名/顺序/默认值(与登记文档同源)。"""
|
||||
spec = inspect.signature(apply_runtime_event)
|
||||
self.assertEqual(list(spec.parameters), TRUE_SOURCE_PARAMS)
|
||||
for name in TRUE_SOURCE_PARAMS:
|
||||
@ -156,9 +199,11 @@ class ReturnKeysCase(unittest.TestCase):
|
||||
self.addCleanup(compat.uninstall)
|
||||
|
||||
def _apply(self, **kw):
|
||||
base = {"tenant_id": 7, "world_id": 1, "session_id": 11,
|
||||
base = {"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION,
|
||||
"event_type": "unit.move", "payload": {"dx": 1}}
|
||||
base.update(kw)
|
||||
if "occurred_at" not in kw:
|
||||
base["occurred_at"] = stamp()
|
||||
return compat.apply(**base)
|
||||
|
||||
def test_success_return_keys_exact(self):
|
||||
@ -170,9 +215,13 @@ class ReturnKeysCase(unittest.TestCase):
|
||||
self.assertEqual(r["seq"], 1)
|
||||
self.assertEqual(r["seq_no"], r["seq"]) # 对外别名一致
|
||||
self.assertEqual(r["state_rows"], 1)
|
||||
self.assertEqual(r["channel"], "pbl:rtx:w1:t7")
|
||||
# 通道名以真源实现为准:pbl.world.<tenant>.<world>
|
||||
self.assertEqual(r["channel"], expected_channel())
|
||||
self.assertEqual(r["channel"], "pbl.world.7.1")
|
||||
self.assertIsInstance(r["stmt_count"], int)
|
||||
self.assertGreaterEqual(r["stmt_count"], 2) # 事件 INSERT + 状态 UPSERT
|
||||
self.assertIsInstance(r["latency_ms"], (int, float))
|
||||
self.assertGreaterEqual(r["latency_ms"], 0)
|
||||
self.assertIsInstance(r["states"], list)
|
||||
self.assertEqual(set(r["states"][0].keys()),
|
||||
{"entity_id", "state_version", "action", "state_key",
|
||||
@ -181,21 +230,28 @@ class ReturnKeysCase(unittest.TestCase):
|
||||
self.assertEqual(r["states"][0]["state_version"], 1)
|
||||
|
||||
def test_dedup_return_keys_exact_and_no_second_write(self):
|
||||
r1 = self._apply(state_updates=[su()], client_event_id="ev-1")
|
||||
r2 = self._apply(state_updates=[su(state={"hp": 9})], client_event_id="ev-1")
|
||||
ts = stamp()
|
||||
r1 = self._apply(state_updates=[su()], client_event_id="ev-1", occurred_at=ts)
|
||||
# 幂等命中:同 client_event_id → 同 event_uid,走「已提交事务 + 不重复写」分支
|
||||
r2 = self._apply(state_updates=[su(state={"hp": 9})], client_event_id="ev-1",
|
||||
occurred_at=ts)
|
||||
self.assertEqual(set(r2.keys()), DEDUP_RETURN_KEYS)
|
||||
self.assertIs(r2["dedup"], True)
|
||||
self.assertEqual(r2["event_id"], r1["event_id"]) # 复用既有事件,不新写
|
||||
self.assertEqual(r2["seq"], r1["seq"])
|
||||
self.assertEqual(r2["state_rows"], 0)
|
||||
self.assertIsNone(r2["latency_ms"])
|
||||
# 真源语义:幂等命中**仍然计时**(tx_write.py:442 在返回前回填 latency_ms=ms),
|
||||
# 故断言「是数值且 >= 0」,不断言 None(旧断言与真源实现不符,QC #4)。
|
||||
self.assertIsInstance(r2["latency_ms"], (int, float))
|
||||
self.assertGreaterEqual(r2["latency_ms"], 0)
|
||||
self.assertEqual(r2["channel"], expected_channel())
|
||||
self.assertEqual(self.eng.count(EVENT_TABLE), 1)
|
||||
self.assertEqual(self.eng.count(STATE_TABLE), 1)
|
||||
self.assertEqual(len(self.eng.broadcasts), 1) # 幂等命中不重复广播
|
||||
|
||||
def test_read_states_rows_shape(self):
|
||||
self._apply(state_updates=[su()], client_event_id="ev-1")
|
||||
rows = compat.read(tenant_id=7, session_id=11)
|
||||
rows = compat.read(tenant_id=TENANT, session_id=SESSION)
|
||||
self.assertIsInstance(rows, list)
|
||||
self.assertEqual(len(rows), 1)
|
||||
row = rows[0]
|
||||
@ -219,7 +275,7 @@ class ReturnKeysCase(unittest.TestCase):
|
||||
self.assertIn("BROADCAST", kinds)
|
||||
self.assertGreater(kinds.index("BROADCAST"), kinds.index("COMMIT"),
|
||||
"广播必须在事务提交之后(M11b-3 契约)")
|
||||
self.assertEqual(self.eng.broadcasts[0]["channel"], "pbl:rtx:w1:t7")
|
||||
self.assertEqual(self.eng.broadcasts[0]["channel"], expected_channel())
|
||||
self.assertEqual(self.eng.broadcasts[0]["event"]["event_type"], "unit.move")
|
||||
|
||||
def test_event_table_is_append_only(self):
|
||||
@ -244,7 +300,7 @@ class GateErrorCase(unittest.TestCase):
|
||||
self.addCleanup(compat.uninstall)
|
||||
|
||||
def _expect(self, code, **kw):
|
||||
base = {"tenant_id": 7, "world_id": 1, "session_id": 11,
|
||||
base = {"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION,
|
||||
"event_type": "x", "payload": {}}
|
||||
base.update(kw)
|
||||
with self.assertRaises(RtxError) as ctx:
|
||||
@ -268,13 +324,14 @@ class GateErrorCase(unittest.TestCase):
|
||||
self.assertEqual(self.eng.count(STATE_TABLE), 0)
|
||||
|
||||
def test_state_conflict_rolls_back(self):
|
||||
compat.apply(tenant_id=7, world_id=1, session_id=11, event_type="unit.move",
|
||||
payload={}, state_updates=[su()], client_event_id="ev-1")
|
||||
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
|
||||
event_type="unit.move", payload={}, state_updates=[su()],
|
||||
client_event_id="ev-1", occurred_at=stamp())
|
||||
before_evt = self.eng.count(EVENT_TABLE)
|
||||
before_state = self.eng.count(STATE_TABLE)
|
||||
self._expect(ERR_STATE_CONFLICT, event_type="x",
|
||||
state_updates=[su(state={"hp": 0})],
|
||||
client_event_id="ev-3", base_version=999)
|
||||
client_event_id="ev-3", base_version=999, occurred_at=stamp())
|
||||
kinds = compat.trace_kinds(self.eng)
|
||||
self.assertEqual(kinds[-1], "ROLLBACK", "冲突分支必须回滚,不得 COMMIT")
|
||||
self.assertEqual(self.eng.count(EVENT_TABLE), before_evt)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user