wedata3-feature-engineering 0.2.5.dev2__tar.gz → 0.2.6.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/PKG-INFO +4 -2
  2. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/setup.py +3 -2
  3. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_cleanup_packed.py +95 -25
  4. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_client.py +41 -0
  5. wedata3_feature_engineering-0.2.6.dev1/tests/test_packed_codec.py +380 -0
  6. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_redis_packed.py +145 -104
  7. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_sync_template.py +86 -7
  8. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_table_client_dispatch.py +55 -10
  9. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/cloud_sdk_client/models.py +2 -2
  10. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/constants/constants.py +1 -1
  11. wedata3_feature_engineering-0.2.6.dev1/wedata/common/online_store/__init__.py +7 -0
  12. wedata3_feature_engineering-0.2.6.dev1/wedata/common/online_store/packed_codec.py +653 -0
  13. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/__init__.py +1 -1
  14. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/client.py +39 -12
  15. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/infra/online_store/cleanup_packed.py +82 -45
  16. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/infra/online_store/redis_packed.py +158 -197
  17. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/table_client/table_client.py +128 -35
  18. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata3_feature_engineering.egg-info/PKG-INFO +4 -2
  19. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata3_feature_engineering.egg-info/SOURCES.txt +2 -1
  20. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata3_feature_engineering.egg-info/requires.txt +2 -0
  21. wedata3_feature_engineering-0.2.5.dev2/tests/test_packed_codec.py +0 -291
  22. wedata3_feature_engineering-0.2.5.dev2/wedata/feature_engineering/feast/infra/online_store/packed_codec.py +0 -520
  23. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/README.md +0 -0
  24. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/setup.cfg +0 -0
  25. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_common_utils.py +0 -0
  26. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_feast_client_validate_keys.py +0 -0
  27. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_feature_spec_training_set.py +0 -0
  28. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_feature_store.py +0 -0
  29. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_feature_table_client_validate_keys.py +0 -0
  30. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/tests/test_table_client.py +0 -0
  31. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/__init__.py +0 -0
  32. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/__init__.py +0 -0
  33. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/base_table_client/__init__.py +0 -0
  34. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/base_table_client/base.py +0 -0
  35. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/cloud_sdk_client/__init__.py +0 -0
  36. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/cloud_sdk_client/client.py +0 -0
  37. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/cloud_sdk_client/utils.py +0 -0
  38. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/constants/__init__.py +0 -0
  39. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/constants/engine_types.py +0 -0
  40. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/__init__.py +0 -0
  41. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/column_info.py +0 -0
  42. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/environment_variables.py +0 -0
  43. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature.py +0 -0
  44. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_column_info.py +0 -0
  45. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_function.py +0 -0
  46. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_lookup.py +0 -0
  47. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_spec.py +0 -0
  48. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_spec_constants.py +0 -0
  49. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_table.py +0 -0
  50. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/feature_table_info.py +0 -0
  51. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/function_info.py +0 -0
  52. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/on_demand_column_info.py +0 -0
  53. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/source_data_column_info.py +0 -0
  54. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/entities/training_set.py +0 -0
  55. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/feast_client/__init__.py +0 -0
  56. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/feast_client/feast_client.py +0 -0
  57. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/log/__init__.py +0 -0
  58. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/log/logger.py +0 -0
  59. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/protos/__init__.py +0 -0
  60. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/protos/feature_store_pb2.py +0 -0
  61. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/spark_client/__init__.py +0 -0
  62. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/spark_client/spark_client.py +0 -0
  63. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/__init__.py +0 -0
  64. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/common_utils.py +0 -0
  65. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/env_utils.py +0 -0
  66. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/feature_lookup_utils.py +0 -0
  67. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/feature_spec_utils.py +0 -0
  68. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/feature_utils.py +0 -0
  69. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/on_demand_utils.py +0 -0
  70. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/schema_utils.py +0 -0
  71. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/signature_utils.py +0 -0
  72. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/topological_sort.py +0 -0
  73. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/training_set_utils.py +0 -0
  74. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/uc_utils.py +0 -0
  75. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/common/utils/validation_utils.py +0 -0
  76. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/__init__.py +0 -0
  77. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/infra/__init__.py +0 -0
  78. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/infra/online_store/__init__.py +0 -0
  79. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/feast/infra/online_store/postgres.py +0 -0
  80. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/ml_training_client/__init__.py +0 -0
  81. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/ml_training_client/ml_training_client.py +0 -0
  82. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/mlflow_model.py +0 -0
  83. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_engineering/table_client/__init__.py +0 -0
  84. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/__init__.py +0 -0
  85. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/client.py +0 -0
  86. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/common/__init__.py +0 -0
  87. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/common/store_config/__init__.py +0 -0
  88. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/common/store_config/redis.py +0 -0
  89. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/constants/__init__.py +0 -0
  90. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/constants/engine_types.py +0 -0
  91. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/feature_table_client/__init__.py +0 -0
  92. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/feature_table_client/feature_table_client.py +0 -0
  93. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/mlflow_model.py +0 -0
  94. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/training_set_client/__init__.py +0 -0
  95. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/feature_store/training_set_client/training_set_client.py +0 -0
  96. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/__init__.py +0 -0
  97. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/interpol.py +0 -0
  98. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/intervals.py +0 -0
  99. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/io.py +0 -0
  100. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/ml.py +0 -0
  101. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/resample.py +0 -0
  102. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/tsdf.py +0 -0
  103. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata/tempo/utils.py +0 -0
  104. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata3_feature_engineering.egg-info/dependency_links.txt +0 -0
  105. {wedata3_feature_engineering-0.2.5.dev2 → wedata3_feature_engineering-0.2.6.dev1}/wedata3_feature_engineering.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wedata3-feature-engineering
3
- Version: 0.2.5.dev2
3
+ Version: 0.2.6.dev1
4
4
  Summary: Wedata Feature Engineering Library Development
5
5
  Home-page:
6
6
  Author: meahqian
@@ -9,11 +9,12 @@ License: Apache 2.0
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: Apache Software License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.9
12
+ Requires-Python: >=3.10
13
13
  Description-Content-Type: text/markdown
14
14
  Requires-Dist: pandas>=1.0.0
15
15
  Requires-Dist: feast[postgres,redis]==0.49.0
16
16
  Requires-Dist: grpcio==1.74.0
17
+ Requires-Dist: msgpack<2,>=1.0
17
18
  Requires-Dist: tencentcloud-sdk-python
18
19
  Requires-Dist: ipython
19
20
  Requires-Dist: msgpack<2.0.0,>=1.0.0
@@ -25,6 +26,7 @@ Provides-Extra: dev
25
26
  Requires-Dist: flake8; extra == "dev"
26
27
  Requires-Dist: pytest; extra == "dev"
27
28
  Requires-Dist: python-dotenv; extra == "dev"
29
+ Requires-Dist: fakeredis[lua]; extra == "dev"
28
30
  Dynamic: author
29
31
  Dynamic: classifier
30
32
  Dynamic: description
@@ -13,6 +13,7 @@ setup(
13
13
  install_requires=[
14
14
  'pandas>=1.0.0',
15
15
  'feast[redis,postgres]==0.49.0', 'grpcio==1.74.0',
16
+ 'msgpack>=1.0,<2',
16
17
  'tencentcloud-sdk-python',
17
18
  'ipython',
18
19
  # Redis 整行打包在线存储(见 doc/plan/redis-packed-codec-design.md)
@@ -21,9 +22,9 @@ setup(
21
22
  extras_require={
22
23
  'mlflow2': ['mlflow==2.17.2',],
23
24
  'mlflow3': ['mlflow>=3.10.0,<3.11.0'],
24
- 'dev': ['flake8', 'pytest', 'python-dotenv'],
25
+ 'dev': ['flake8', 'pytest', 'python-dotenv', 'fakeredis[lua]'],
25
26
  },
26
- python_requires='>=3.9',
27
+ python_requires='>=3.10',
27
28
  author="meahqian",
28
29
  author_email="",
29
30
  description="Wedata Feature Engineering Library Development",
@@ -8,6 +8,9 @@ cleanup_packed 单元测试(fakeredis)。
8
8
  STALE/REJECTED 删、超时 WRITING 转 ABANDONED 删、PURGED 条目过期删
9
9
  3. 孤儿 __meta__ 清理
10
10
  4. purge_batch 幂等、extend_active_ttl 应急路径
11
+ 5. drop_all 整表删除:控制 key 与数据 key 都删,不碰其他表
12
+
13
+ key 格式与 BatchInfo 统一走 common codec(与推理侧同一份实现)。
11
14
  """
12
15
 
13
16
  import time
@@ -15,10 +18,27 @@ import time
15
18
  import fakeredis
16
19
  import pytest
17
20
 
18
- from wedata.feature_engineering.feast.infra.online_store import packed_codec as pc
21
+ from wedata.common.online_store import packed_codec as pc
22
+ from wedata.common.online_store.packed_codec import SchemaMeta
19
23
  from wedata.feature_engineering.feast.infra.online_store import cleanup_packed as cp
20
24
 
21
- PREFIX = "1001:ab12cd34ef"
25
+ PREFIX = "1001:ab23cd45efgh"
26
+
27
+ META = SchemaMeta(
28
+ codec_version=pc.CODEC_VERSION,
29
+ schema_version="a3f2c1d8",
30
+ fields=(("a", "INT64"),),
31
+ entity_keys=("u",),
32
+ created_at=0,
33
+ source_table="cat.db.t",
34
+ )
35
+
36
+
37
+ def all_keys(client, prefix=PREFIX):
38
+ keys = set()
39
+ for pattern in pc.table_key_patterns(prefix):
40
+ keys.update(k.decode() if isinstance(k, bytes) else k for k in client.scan_iter(match=pattern))
41
+ return sorted(keys)
22
42
 
23
43
 
24
44
  def make_client():
@@ -29,20 +49,15 @@ def register(client, prefix, sv, dv, state, watermark=1000, run_start=None, upda
29
49
  """登记一个批次并可选写两个数据 key。"""
30
50
  member = pc.build_batch_member(sv, dv)
31
51
  info_raw = pc.build_batch_info(
32
- watermark_ms=watermark,
52
+ watermark=watermark,
33
53
  run_start_ms=run_start if run_start is not None else int(time.time() * 1000),
34
54
  state=state,
35
- updated_at_s=updated_at if updated_at is not None else int(time.time()),
55
+ updated_at=updated_at if updated_at is not None else int(time.time()),
56
+ retired_at=retired_at,
36
57
  )
37
- # build_batch_info 无 retired_at 槽位,retired 状态在 hash 里补
38
- if retired_at is not None:
39
- import json
40
- info = json.loads(info_raw)
41
- info["retired_at"] = retired_at
42
- info_raw = json.dumps(info, separators=(",", ":"))
43
58
  client.hset(pc.versions_key(prefix), member, info_raw)
44
- client.set(pc.data_key(prefix, sv, dv, "u1"), pc.encode_row([1, 2, 3], ts_ms=watermark))
45
- client.set(pc.data_key(prefix, sv, dv, "u2"), pc.encode_row([4, 5, 6], ts_ms=watermark))
59
+ client.set(pc.data_key(prefix, sv, dv, "u1"), pc.encode_row(META, 1, {"a": 1}))
60
+ client.set(pc.data_key(prefix, sv, dv, "u2"), pc.encode_row(META, 2, {"a": 2}))
46
61
  return member
47
62
 
48
63
 
@@ -73,17 +88,15 @@ class TestPrecheck:
73
88
  client = make_client()
74
89
  register(client, PREFIX, sv(1), dv(1), pc.STATE_STALE)
75
90
  cp.precheck_and_purge(client, PREFIX)
76
- # STALE 批次数据已被清掉(fakeredis 返回 bytes,统一解码比较)
77
- remain = sorted(k.decode() if isinstance(k, bytes) else k
78
- for k in client.scan_iter(match=f"{PREFIX}:*"))
79
- assert remain == [pc.versions_key(PREFIX)]
91
+ # STALE 批次数据已被清掉,只剩登记表
92
+ assert all_keys(client) == [pc.versions_key(PREFIX)]
80
93
 
81
94
  def test_abandoned_writing_purged(self):
82
95
  client = make_client()
83
96
  # updated_at 设为 7 小时前
84
97
  register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, updated_at=int(time.time()) - 7 * 3600)
85
98
  cp.precheck_and_purge(client, PREFIX)
86
- info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), pc.build_batch_member(sv(1), dv(1))).decode())
99
+ info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), pc.build_batch_member(sv(1), dv(1))))
87
100
  assert info[pc.BATCH_STATE] == pc.STATE_PURGED
88
101
  assert not client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
89
102
 
@@ -103,7 +116,7 @@ class TestCleanupRules:
103
116
  client, active = self._setup()
104
117
  cp.cleanup(client, PREFIX)
105
118
  assert client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
106
- info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), active).decode())
119
+ info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), active))
107
120
  assert info[pc.BATCH_STATE] == pc.STATE_ACTIVE
108
121
 
109
122
  def test_newest_retired_kept_old_retired_deleted(self):
@@ -139,19 +152,27 @@ class TestCleanupRules:
139
152
  cp.cleanup(client, PREFIX)
140
153
  assert client.hget(pc.versions_key(PREFIX), member) is None # 登记条目也删了
141
154
 
142
- def test_corrupted_entry_treated_as_abandoned(self):
155
+ def test_corrupted_info_treated_as_abandoned_and_purged(self):
156
+ client, active = self._setup()
157
+ member = register(client, PREFIX, sv(2), dv(2), pc.STATE_WRITING)
158
+ client.hset(pc.versions_key(PREFIX), member, "not-json")
159
+ assert cp.cleanup(client, PREFIX) == 1
160
+ assert not client.exists(pc.data_key(PREFIX, sv(2), dv(2), "u1"))
161
+ info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))
162
+ assert info[pc.BATCH_STATE] == pc.STATE_PURGED
163
+
164
+ def test_malformed_member_dropped_without_blocking(self):
143
165
  client, active = self._setup()
144
166
  client.hset(pc.versions_key(PREFIX), "garbage-member", "not-json")
145
- deleted = cp.cleanup(client, PREFIX)
146
- assert deleted >= 0 # 不抛错
147
- assert client.hget(pc.versions_key(PREFIX), "garbage-member") is not None # 无法解析,原样保留
167
+ cp.cleanup(client, PREFIX) # 不抛错,门禁不会被脏条目卡死
168
+ assert client.hget(pc.versions_key(PREFIX), "garbage-member") is None
148
169
 
149
170
  def test_orphan_meta_removed(self):
150
171
  client, active = self._setup()
151
172
  # ACTIVE 批次的 meta 正常存在
152
- client.set(pc.meta_key(PREFIX, sv(1)), "{}")
173
+ client.set(pc.meta_key(PREFIX, sv(1)), META.to_bytes())
153
174
  # sv=99 的 meta 存在但无任何批次
154
- client.set(pc.meta_key(PREFIX, sv(99)), "{}")
175
+ client.set(pc.meta_key(PREFIX, sv(99)), META.to_bytes())
155
176
  cp.cleanup(client, PREFIX)
156
177
  assert not client.exists(pc.meta_key(PREFIX, sv(99)))
157
178
  # active 批次的 meta 保留
@@ -169,7 +190,7 @@ class TestUtilities:
169
190
  cp.purge_batch(client, PREFIX, member)
170
191
  assert not client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
171
192
  cp.purge_batch(client, PREFIX, member) # 再跑一次不抛错
172
- info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member).decode())
193
+ info = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))
173
194
  assert info[pc.BATCH_STATE] == pc.STATE_PURGED
174
195
 
175
196
  def test_purge_does_not_touch_other_batches(self):
@@ -193,3 +214,52 @@ class TestUtilities:
193
214
  client = make_client()
194
215
  with pytest.raises(cp.CleanupError):
195
216
  cp.extend_active_ttl(client, PREFIX, 3600)
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # drop_all(删除在线表的清理作业)
221
+ # ---------------------------------------------------------------------------
222
+
223
+ class TestDropAll:
224
+ def _populate(self, client, prefix):
225
+ member = register(client, prefix, sv(1), dv(1), pc.STATE_ACTIVE)
226
+ client.set(pc.active_key(prefix), member)
227
+ client.set(pc.meta_key(prefix, sv(1)), META.to_bytes())
228
+
229
+ def test_removes_control_and_data_keys(self):
230
+ client = make_client()
231
+ self._populate(client, PREFIX)
232
+ assert len(all_keys(client)) == 5 # active / versions / meta / 2 条数据
233
+ assert cp.drop_all(client, PREFIX) == 5
234
+ assert all_keys(client) == []
235
+ assert cp.drop_all(client, PREFIX) == 0 # 幂等
236
+
237
+ def test_other_tables_untouched(self):
238
+ client = make_client()
239
+ self._populate(client, PREFIX)
240
+ for other in ("1001:ab12cd34ef0", "1002:ab12cd34ef"):
241
+ self._populate(client, other)
242
+ cp.drop_all(client, PREFIX)
243
+ assert all_keys(client, "1001:ab12cd34ef0") and all_keys(client, "1002:ab12cd34ef")
244
+
245
+ def test_malformed_prefix_rejected(self):
246
+ """防呆:畸形前缀(裸 workspaceId / 短哈希)拒绝执行,SCAN 模式会跨表清库。"""
247
+ import pytest
248
+
249
+ client = make_client()
250
+ self._populate(client, PREFIX)
251
+ for bad in ("1001", "1001:", "1001:ab12cd34ef", "1001:ab12cd34ef0", "vpc-1001:ab12cd34efgh"):
252
+ with pytest.raises(cp.CleanupError):
253
+ cp.drop_all(client, bad)
254
+ assert len(all_keys(client)) == 5 # 一个 key 都没动
255
+
256
+ def test_redis_error_propagates(self, monkeypatch):
257
+ client = make_client()
258
+ self._populate(client, PREFIX)
259
+
260
+ def boom(*args, **kwargs):
261
+ raise cp.redis.ConnectionError("down")
262
+
263
+ monkeypatch.setattr(client, "unlink", boom)
264
+ with pytest.raises(cp.redis.ConnectionError):
265
+ cp.drop_all(client, PREFIX)
@@ -1625,6 +1625,47 @@ class TestOnlineTableOperations:
1625
1625
  assert call_kwargs["trigger"] is mock_trigger
1626
1626
  assert call_kwargs["online_catalog_name"] == "online_cat"
1627
1627
 
1628
+ def test_publish_table_redis_without_online_names(self):
1629
+ """Redis 发布:只传离线表与类型,在线库表名保持 None 透传给内部客户端校验"""
1630
+ client = _make_client()
1631
+ client.publish_table(
1632
+ catalog_name="cat", schema_name="schema", table_name="tbl", online_store_type="REDIS"
1633
+ )
1634
+ client._feature_table_client.publish_table.assert_called_once_with(
1635
+ catalog_name="cat",
1636
+ schema_name="schema",
1637
+ table_name="tbl",
1638
+ online_db_name=None,
1639
+ online_table_name=None,
1640
+ online_catalog_name=None,
1641
+ trigger=None,
1642
+ online_store_type="REDIS",
1643
+ )
1644
+
1645
+ def test_read_table_online_delegates(self):
1646
+ """read_table(is_online=True) 透传在线读取参数"""
1647
+ client = _make_client()
1648
+ client._parse_table_name = lambda n, d, c: (n, d, c)
1649
+ rows = [{"member_id": "10086"}]
1650
+ client.read_table("tbl", "db", "cat", is_online=True, entity_rows=rows, redis_connection_string="h:6379")
1651
+ client._feature_table_client.read_table.assert_called_once_with(
1652
+ name="tbl",
1653
+ database_name="db",
1654
+ catalog_name="cat",
1655
+ is_online=True,
1656
+ entity_rows=rows,
1657
+ redis_connection_string="h:6379",
1658
+ )
1659
+
1660
+ def test_read_table_offline_call_unchanged(self):
1661
+ """默认离线读取:调用参数与改造前一致"""
1662
+ client = _make_client()
1663
+ client._parse_table_name = lambda n, d, c: (n, d, c)
1664
+ client.read_table("tbl", "db", "cat")
1665
+ client._feature_table_client.read_table.assert_called_once_with(
1666
+ name="tbl", database_name="db", catalog_name="cat"
1667
+ )
1668
+
1628
1669
  def test_drop_online_table_delegates(self):
1629
1670
  """drop_online_table 应委托给 _feature_table_client.drop_online_table"""
1630
1671
  client = _make_client()
@@ -0,0 +1,380 @@
1
+ import datetime as dt
2
+ import json
3
+ import os
4
+ import random
5
+ import subprocess
6
+ import sys
7
+ from decimal import Decimal
8
+ from pathlib import Path
9
+
10
+ import msgpack
11
+ import pytest
12
+
13
+ from wedata.common.online_store import packed_codec as pc
14
+ from wedata.common.online_store.packed_codec import (
15
+ PackedCodecError,
16
+ SchemaMeta,
17
+ UnsupportedCodecVersion,
18
+ )
19
+
20
+ PREFIX = "17893778831981982:ie3ton4o6vrh"
21
+ FIELDS = (("age", "INT64"), ("city_id", "INT32"), ("ctr_7d", "DOUBLE"), ("name", "STRING"))
22
+
23
+
24
+ def make_meta(fields=FIELDS, entity_keys=("customer_id",)):
25
+ return SchemaMeta(
26
+ codec_version=pc.CODEC_VERSION,
27
+ schema_version=pc.compute_schema_version(fields, entity_keys),
28
+ fields=tuple(fields),
29
+ entity_keys=tuple(entity_keys),
30
+ created_at=1789892926028,
31
+ source_table="prod_cat.dws.member_offline_feat",
32
+ )
33
+
34
+
35
+ # ---------------------------------------------------------------- key(§2.6)
36
+
37
+
38
+ def test_key_examples_match_doc():
39
+ assert pc.active_key(PREFIX) == "{17893778831981982:ie3ton4o6vrh}:__active__"
40
+ assert pc.meta_key(PREFIX, "a3f2c1d8") == "{17893778831981982:ie3ton4o6vrh}:__meta__:a3f2c1d8"
41
+ assert pc.versions_key(PREFIX) == "{17893778831981982:ie3ton4o6vrh}:__versions__"
42
+ key = pc.data_key(PREFIX, "a3f2c1d8", "mu9k18p8a7", "1000000123")
43
+ assert key == "17893778831981982:ie3ton4o6vrh:a3f2c1d8:mu9k18p8a7:1000000123"
44
+ assert len(key.encode()) == 61
45
+
46
+
47
+ def test_control_keys_share_slot_and_data_key_has_no_braces():
48
+ from redis.crc import key_slot
49
+
50
+ slots = {key_slot(k.encode()) for k in (
51
+ pc.active_key(PREFIX), pc.meta_key(PREFIX, "a3f2c1d8"), pc.versions_key(PREFIX))}
52
+ assert len(slots) == 1
53
+ key = pc.data_key(PREFIX, "a3f2c1d8", "mu9k18p8a7", pc.build_entity_key_from_values(["{x}"]))
54
+ assert "{" not in key and "}" not in key
55
+
56
+
57
+ def test_data_key_rejects_unescaped_braces_and_bad_prefix():
58
+ with pytest.raises(PackedCodecError):
59
+ pc.data_key(PREFIX, "a3f2c1d8", "mu9k18p8a7", "{grp}1")
60
+ with pytest.raises(PackedCodecError):
61
+ pc.active_key("{ws}:tid")
62
+ with pytest.raises(PackedCodecError):
63
+ pc.data_key_prefix(PREFIX, "a3:f2", "mu9k18p8a7")
64
+
65
+
66
+ # ---------------------------------------------------------------- ek(§2.6.3)
67
+
68
+
69
+ def test_entity_key_separator_and_percent_no_collision():
70
+ assert pc.build_entity_key_from_values(["a|b", "c"]) != pc.build_entity_key_from_values(["a", "b|c"])
71
+ assert pc.build_entity_key_from_values(["%"]) != pc.build_entity_key_from_values(["%25"])
72
+ assert pc.build_entity_key_from_values(["|"]) != pc.build_entity_key_from_values(["%7C"])
73
+ assert pc.build_entity_key_from_values(["shopA", 2026]) == "shopA|2026"
74
+
75
+
76
+ def test_entity_key_braces_spread_over_slots():
77
+ from redis.crc import key_slot
78
+
79
+ base = pc.data_key_prefix(PREFIX, "a3f2c1d8", "mu9k18p8a7")
80
+ slots = {key_slot((base + pc.build_entity_key_from_values([f"{{grp}}{i}"])).encode()) for i in range(10000)}
81
+ assert len(slots) > 7000
82
+
83
+
84
+ def test_entity_key_random_inputs_never_contain_braces():
85
+ rnd = random.Random(7)
86
+ alphabet = "{}|%:ab1 "
87
+ for _ in range(2000):
88
+ vals = ["".join(rnd.choice(alphabet) for _ in range(rnd.randint(1, 8))) for _ in range(rnd.randint(1, 3))]
89
+ key = pc.data_key(PREFIX, "a3f2c1d8", "mu9k18p8a7", pc.build_entity_key_from_values(vals))
90
+ assert "{" not in key and "}" not in key
91
+
92
+
93
+ def test_entity_value_normalization():
94
+ assert pc.entity_value_to_str(1000000123) == "1000000123"
95
+ assert pc.entity_value_to_str(1000000123.0) == "1000000123"
96
+ assert pc.entity_value_to_str(True) == "true"
97
+ assert pc.entity_value_to_str(1.5) == "1.5"
98
+ for bad in (None, float("nan"), b"x", Decimal("NaN"), object()):
99
+ with pytest.raises(PackedCodecError):
100
+ pc.entity_value_to_str(bad)
101
+
102
+
103
+ def test_entity_value_normalization_decimal():
104
+ assert pc.entity_value_to_str(Decimal("123")) == "123"
105
+ assert pc.entity_value_to_str(Decimal("123.00")) == pc.entity_value_to_str(123) == "123"
106
+ assert pc.entity_value_to_str(Decimal("1E+2")) == "100"
107
+ assert pc.entity_value_to_str(Decimal("1.50")) == pc.entity_value_to_str(1.5) == "1.5"
108
+ assert pc.entity_value_to_str(Decimal("-0.25")) == pc.entity_value_to_str(-0.25)
109
+
110
+
111
+ def test_entity_value_normalization_datetime():
112
+ assert pc.entity_value_to_str(dt.date(2026, 1, 2)) == "2026-01-02"
113
+ assert pc.entity_value_to_str(dt.datetime(2026, 1, 2, 3, 4, 5)) == "2026-01-02 03:04:05"
114
+ assert pc.entity_value_to_str(dt.datetime(2026, 1, 2, 3, 4, 5, 120000)) == "2026-01-02 03:04:05.12"
115
+ cst = dt.timezone(dt.timedelta(hours=8))
116
+ assert pc.entity_value_to_str(dt.datetime(2026, 1, 2, 8, 0, 0, tzinfo=cst)) == "2026-01-02 00:00:00"
117
+
118
+
119
+ def test_entity_value_normalization_numpy_and_pandas():
120
+ np = pytest.importorskip("numpy")
121
+ pd = pytest.importorskip("pandas")
122
+ assert pc.entity_value_to_str(np.int64(7)) == "7"
123
+ assert pc.entity_value_to_str(np.float64(7.0)) == "7"
124
+ assert pc.entity_value_to_str(np.bool_(True)) == "true"
125
+ ts = "2026-01-02T03:04:05.123456789"
126
+ # datetime64[ns].item() 是纳秒整数,必须按时间而不是整数规范化
127
+ assert pc.entity_value_to_str(np.datetime64(ts)) == "2026-01-02 03:04:05.123456"
128
+ assert pc.entity_value_to_str(pd.Timestamp("2026-01-02 03:04:05")) == "2026-01-02 03:04:05"
129
+ with pytest.raises(PackedCodecError):
130
+ pc.entity_value_to_str(pd.NaT)
131
+
132
+
133
+ def test_build_entity_key_uses_meta_order():
134
+ meta = make_meta(entity_keys=("shop_id", "dt"))
135
+ assert pc.build_entity_key(meta, {"dt": 2026, "shop_id": "A"}) == "A|2026"
136
+ with pytest.raises(PackedCodecError):
137
+ pc.build_entity_key(meta, {"shop_id": "A"})
138
+
139
+
140
+ # ---------------------------------------------------------------- 编解码(§3)
141
+
142
+
143
+ def test_encode_decode_roundtrip():
144
+ meta = make_meta()
145
+ values = {"age": -5, "city_id": 0, "ctr_7d": 0.25, "name": ""}
146
+ ts, out = pc.decode_row(meta, pc.encode_row(meta, 123, values))
147
+ assert ts == 123 and out == values
148
+ big = {"age": 2**63 - 1}
149
+ assert pc.decode_row(meta, pc.encode_row(meta, None, big))[1] == big
150
+
151
+
152
+ def test_nil_not_in_result_and_requested_projection():
153
+ meta = make_meta()
154
+ raw = pc.encode_row(meta, 1, {"age": None, "ctr_7d": float("nan"), "name": "x"})
155
+ _, out = pc.decode_row(meta, raw)
156
+ assert out == {"name": "x"}
157
+ assert pc.decode_row(meta, raw, requested=["name", "age"])[1] == {"name": "x"}
158
+ with pytest.raises(PackedCodecError):
159
+ pc.decode_row(meta, raw, requested=["missing"])
160
+
161
+
162
+ def test_length_tolerance():
163
+ meta = make_meta()
164
+ short = msgpack.packb([1, 7], use_bin_type=True)
165
+ assert pc.decode_row(meta, short)[1] == {"age": 7}
166
+ long = msgpack.packb([1, 1, 2, 3.0, "n", "extra"], use_bin_type=True)
167
+ assert pc.decode_row(meta, long)[1] == {"age": 1, "city_id": 2, "ctr_7d": 3.0, "name": "n"}
168
+
169
+
170
+ def test_meta_roundtrip_and_codec_version_guard():
171
+ meta = make_meta()
172
+ assert SchemaMeta.from_bytes(meta.to_bytes()) == meta
173
+ newer = dict(meta.to_dict(), codec_version=pc.CODEC_VERSION + 1)
174
+ with pytest.raises(UnsupportedCodecVersion):
175
+ SchemaMeta.from_bytes(msgpack.packb(newer, use_bin_type=True))
176
+ with pytest.raises(PackedCodecError):
177
+ SchemaMeta.from_bytes(b"\xc1")
178
+
179
+
180
+ def test_validate_string_lengths():
181
+ pc.validate_string_lengths({"a": "x" * 64})
182
+ with pytest.raises(PackedCodecError):
183
+ pc.validate_string_lengths({"a": "x" * 65})
184
+
185
+
186
+ # ---------------------------------------------------------------- 版本号(§4 / §2.2.2)
187
+
188
+
189
+ def test_schema_version_order_sensitive_and_stable():
190
+ v1 = pc.compute_schema_version(FIELDS, ["customer_id"])
191
+ assert v1 == pc.compute_schema_version(list(FIELDS), ("customer_id",))
192
+ assert len(v1) == 8
193
+ assert v1 != pc.compute_schema_version(tuple(reversed(FIELDS)), ["customer_id"])
194
+ assert v1 != pc.compute_schema_version(FIELDS, ["user_id"])
195
+
196
+
197
+ def test_schema_version_stable_across_processes_with_random_hash_seed():
198
+ code = (
199
+ "from wedata.common.online_store.packed_codec import compute_schema_version as c;"
200
+ "print(c([('age','INT64'),('ctr','DOUBLE')],['customer_id']))"
201
+ )
202
+ root = Path(__file__).resolve().parents[1]
203
+ outs = set()
204
+ for seed in ("1", "2", "random"):
205
+ env = dict(os.environ, PYTHONHASHSEED=seed, PYTHONPATH=str(root))
206
+ outs.add(subprocess.check_output([sys.executable, "-c", code], env=env, text=True).strip())
207
+ assert len(outs) == 1
208
+
209
+
210
+ def test_new_data_version_format():
211
+ assert pc.new_data_version(1789892926028, "a7") == "mu9k18p8a7"
212
+ dv = pc.new_data_version(1789892926028)
213
+ assert len(dv) == 10 and dv.startswith("mu9k18p8")
214
+ assert pc.new_data_version(1, "00") == "0000000100"
215
+ with pytest.raises(ValueError):
216
+ pc.new_data_version(1, "A!")
217
+
218
+
219
+ def test_parse_active_pointer():
220
+ assert pc.parse_active_pointer(b"a3f2c1d8:mu9k18p8a7") == ("a3f2c1d8", "mu9k18p8a7")
221
+ for bad in ("", "abc", "a:b:c", ":b", None):
222
+ with pytest.raises(PackedCodecError):
223
+ pc.parse_active_pointer(bad)
224
+
225
+
226
+ # ---------------------------------------------------------------- feast 适配
227
+
228
+
229
+ def test_build_meta_from_feature_view_keeps_features_order():
230
+ from feast import Entity, FeatureView, Field, FileSource, ValueType
231
+ from feast.types import Float64, Int64, String
232
+
233
+ fv = FeatureView(
234
+ name="fv",
235
+ entities=[Entity(name="customer_id", join_keys=["customer_id"], value_type=ValueType.INT64)],
236
+ schema=[Field(name="customer_id", dtype=Int64), Field(name="z", dtype=String),
237
+ Field(name="a", dtype=Float64), Field(name="m", dtype=Int64)],
238
+ source=FileSource(path="x.parquet", timestamp_field="ts"),
239
+ )
240
+ meta = pc.build_meta_from_feature_view(fv, "c.s.t", created_at=1)
241
+ assert meta.fields == (("z", "STRING"), ("a", "DOUBLE"), ("m", "INT64"))
242
+ assert meta.entity_keys == ("customer_id",)
243
+ assert meta.schema_version == pc.compute_schema_version(meta.fields, meta.entity_keys)
244
+ protos = pc.to_value_protos(meta, {"z": "s", "a": 1.5, "m": 3})
245
+ assert protos["z"].string_val == "s" and protos["a"].double_val == 1.5 and protos["m"].int64_val == 3
246
+
247
+
248
+ # ---------------------------------------------------------------- CAS Lua(§5.2)
249
+
250
+
251
+ def test_activate_lua_identical_to_verified_script():
252
+ script = Path(__file__).resolve().parents[3] / "science/doc/plan/scripts/active_cas_verify.py"
253
+ if not script.exists():
254
+ pytest.skip("验证脚本不在当前检出中")
255
+ src = script.read_text(encoding="utf-8")
256
+ start = src.index('ACTIVATE_LUA = r"""') + len('ACTIVATE_LUA = r"""')
257
+ assert src[start:src.index('"""', start)] == pc.ACTIVATE_LUA
258
+
259
+
260
+ def _lua_env():
261
+ fakeredis = pytest.importorskip("fakeredis")
262
+ pytest.importorskip("lupa")
263
+ r = fakeredis.FakeRedis(decode_responses=True)
264
+ return r, r.register_script(pc.ACTIVATE_LUA)
265
+
266
+
267
+ def _register(r, member, watermark, run_start):
268
+ r.hset(pc.versions_key(PREFIX), member, json.dumps(
269
+ {"state": "WRITING", "watermark": watermark, "run_start": run_start}))
270
+
271
+
272
+ def _activate(script, member):
273
+ sw, prev, reason = script(keys=[pc.active_key(PREFIX), pc.versions_key(PREFIX)], args=[member, 0])
274
+ return int(sw), prev, reason
275
+
276
+
277
+ def _state(r, member):
278
+ return json.loads(r.hget(pc.versions_key(PREFIX), member))["state"]
279
+
280
+
281
+ def test_activate_lua_scenarios():
282
+ r, s = _lua_env()
283
+ active = pc.active_key(PREFIX)
284
+ _register(r, "sv:T", 100, 1000)
285
+ assert _activate(s, "sv:T")[0] == 1 and _state(r, "sv:T") == "ACTIVE"
286
+ _register(r, "sv:T1", 200, 2000)
287
+ assert _activate(s, "sv:T1")[0] == 1 and _state(r, "sv:T") == "RETIRED"
288
+ _register(r, "sv:retry", 150, 3000)
289
+ assert _activate(s, "sv:retry")[2] == "STALE" and r.get(active) == "sv:T1"
290
+ _register(r, "sv:rerun", 200, 2500)
291
+ assert _activate(s, "sv:rerun")[0] == 1
292
+ _register(r, "sv:early", 200, 2400)
293
+ assert _activate(s, "sv:early")[2] == "STALE"
294
+ assert _activate(s, "sv:ghost")[2] == "NEW_NOT_REGISTERED" and r.get(active) == "sv:rerun"
295
+
296
+
297
+ def test_activate_lua_random_order_always_latest():
298
+ rnd = random.Random(11)
299
+ for _ in range(200):
300
+ r, s = _lua_env()
301
+ batches = [(f"sv:{i}", rnd.randint(1, 50), rnd.randint(1, 10_000)) for i in range(6)]
302
+ for m, wm, rs in batches:
303
+ _register(r, m, wm, rs)
304
+ for m, _, _ in rnd.sample(batches, len(batches)):
305
+ _activate(s, m)
306
+ expected = max(batches, key=lambda b: (b[1], b[2]))[0]
307
+ assert r.get(pc.active_key(PREFIX)) == expected
308
+ assert [m for m, _, _ in batches if _state(r, m) == "ACTIVE"] == [expected]
309
+
310
+
311
+ # ---------------------------------------------------------------- 批次登记(§2.4)
312
+
313
+
314
+ def test_batch_member_same_shape_as_active_pointer():
315
+ member = pc.build_batch_member("a3f2c1d8", "mu9k18p8a7")
316
+ assert member == "a3f2c1d8:mu9k18p8a7"
317
+ assert pc.parse_batch_member(member) == pc.parse_active_pointer(member)
318
+ for bad in ("", "a:b", "x{y"):
319
+ with pytest.raises(PackedCodecError):
320
+ pc.build_batch_member(bad, "mu9k18p8a7")
321
+
322
+
323
+ def test_batch_info_roundtrip_keeps_extra_fields():
324
+ raw = pc.build_batch_info(
325
+ watermark=1760227200, run_start_ms=1760245200123, updated_at=1, rows_expected=10, exec_id=None
326
+ )
327
+ info = pc.decode_batch_info(raw.encode())
328
+ assert info == {
329
+ "state": "WRITING",
330
+ "watermark": 1760227200,
331
+ "run_start": 1760245200123,
332
+ "updated_at": 1,
333
+ "rows_expected": 10,
334
+ }
335
+ info["state"] = pc.STATE_PURGED
336
+ assert pc.decode_batch_info(pc.encode_batch_info(info))["rows_expected"] == 10
337
+
338
+
339
+ @pytest.mark.parametrize(
340
+ "raw", ["", "not json", "[]", '{"state":"WRITING","watermark":1}', '{"watermark":1,"run_start":1}']
341
+ )
342
+ def test_decode_batch_info_rejects_corrupted(raw):
343
+ with pytest.raises(PackedCodecError):
344
+ pc.decode_batch_info(raw)
345
+
346
+
347
+ def test_encode_batch_info_rejects_unknown_state():
348
+ with pytest.raises(PackedCodecError):
349
+ pc.build_batch_info(watermark=1, run_start_ms=1, state="DONE")
350
+
351
+
352
+ def test_batch_info_works_with_activate_lua():
353
+ r, s = _lua_env()
354
+ old = pc.build_batch_member("a3f2c1d8", "aaaaaaaaaa")
355
+ new = pc.build_batch_member("a3f2c1d8", "bbbbbbbbbb")
356
+ r.hset(pc.versions_key(PREFIX), old, pc.build_batch_info(watermark=100, run_start_ms=1, rows_expected=5))
357
+ r.hset(pc.versions_key(PREFIX), new, pc.build_batch_info(watermark=200, run_start_ms=2))
358
+ assert _activate(s, old)[0] == 1
359
+ assert _activate(s, new)[0] == 1
360
+ retired = pc.decode_batch_info(r.hget(pc.versions_key(PREFIX), old))
361
+ assert retired["state"] == pc.STATE_RETIRED and retired["rows_expected"] == 5
362
+ assert pc.BATCH_RETIRED_AT in retired
363
+
364
+
365
+ # ---------------------------------------------------------------- SCAN 匹配模式
366
+
367
+
368
+ def test_scan_patterns_cover_exactly_one_table():
369
+ control, data = pc.table_key_patterns(PREFIX)
370
+ assert control == "{17893778831981982:ie3ton4o6vrh}:*"
371
+ assert data == "17893778831981982:ie3ton4o6vrh:*"
372
+ assert pc.batch_data_pattern(PREFIX, "a3f2c1d8", "mu9k18p8a7") == "17893778831981982:ie3ton4o6vrh:a3f2c1d8:mu9k18p8a7:*"
373
+ assert pc.meta_pattern(PREFIX) == "{17893778831981982:ie3ton4o6vrh}:__meta__:*"
374
+ assert pc.meta_key(PREFIX, "a3f2c1d8").startswith(pc.meta_key_prefix(PREFIX))
375
+
376
+
377
+ def test_scan_patterns_escape_glob_metacharacters():
378
+ control, data = pc.table_key_patterns("1:a*b?[c]")
379
+ assert control == r"{1:a\*b\?\[c\]}:*"
380
+ assert data == r"1:a\*b\?\[c\]:*"