wedata3-feature-engineering 0.2.6.dev2__tar.gz → 0.2.6.dev3__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 (104) hide show
  1. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/PKG-INFO +1 -1
  2. wedata3_feature_engineering-0.2.6.dev3/tests/test_build_executor_deps.py +52 -0
  3. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_cleanup_packed.py +140 -0
  4. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_packed_codec.py +45 -0
  5. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_redis_packed.py +49 -0
  6. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_sync_template.py +47 -1
  7. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/constants.py +1 -1
  8. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/online_store/packed_codec.py +64 -6
  9. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/online_store/cleanup_packed.py +160 -4
  10. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/online_store/redis_packed.py +55 -15
  11. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/PKG-INFO +1 -1
  12. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/SOURCES.txt +1 -0
  13. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/README.md +0 -0
  14. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/setup.cfg +0 -0
  15. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/setup.py +0 -0
  16. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_client.py +0 -0
  17. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_common_utils.py +0 -0
  18. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feast_client_validate_keys.py +0 -0
  19. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_spec_training_set.py +0 -0
  20. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_store.py +0 -0
  21. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_table_client_validate_keys.py +0 -0
  22. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_table_client.py +0 -0
  23. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_table_client_dispatch.py +0 -0
  24. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/__init__.py +0 -0
  25. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/__init__.py +0 -0
  26. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/base_table_client/__init__.py +0 -0
  27. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/base_table_client/base.py +0 -0
  28. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/__init__.py +0 -0
  29. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/client.py +0 -0
  30. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/models.py +0 -0
  31. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/utils.py +0 -0
  32. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/__init__.py +0 -0
  33. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/engine_types.py +0 -0
  34. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/__init__.py +0 -0
  35. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/column_info.py +0 -0
  36. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/environment_variables.py +0 -0
  37. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature.py +0 -0
  38. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_column_info.py +0 -0
  39. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_function.py +0 -0
  40. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_lookup.py +0 -0
  41. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_spec.py +0 -0
  42. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_spec_constants.py +0 -0
  43. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_table.py +0 -0
  44. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_table_info.py +0 -0
  45. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/function_info.py +0 -0
  46. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/on_demand_column_info.py +0 -0
  47. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/source_data_column_info.py +0 -0
  48. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/training_set.py +0 -0
  49. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/feast_client/__init__.py +0 -0
  50. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/feast_client/feast_client.py +0 -0
  51. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/log/__init__.py +0 -0
  52. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/log/logger.py +0 -0
  53. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/online_store/__init__.py +0 -0
  54. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/protos/__init__.py +0 -0
  55. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/protos/feature_store_pb2.py +0 -0
  56. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/spark_client/__init__.py +0 -0
  57. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/spark_client/spark_client.py +0 -0
  58. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/__init__.py +0 -0
  59. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/common_utils.py +0 -0
  60. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/env_utils.py +0 -0
  61. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_lookup_utils.py +0 -0
  62. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_spec_utils.py +0 -0
  63. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_utils.py +0 -0
  64. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/on_demand_utils.py +0 -0
  65. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/schema_utils.py +0 -0
  66. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/signature_utils.py +0 -0
  67. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/topological_sort.py +0 -0
  68. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/training_set_utils.py +0 -0
  69. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/uc_utils.py +0 -0
  70. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/validation_utils.py +0 -0
  71. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/__init__.py +0 -0
  72. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/client.py +0 -0
  73. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/__init__.py +0 -0
  74. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/__init__.py +0 -0
  75. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/online_store/__init__.py +0 -0
  76. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/online_store/postgres.py +0 -0
  77. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/ml_training_client/__init__.py +0 -0
  78. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/ml_training_client/ml_training_client.py +0 -0
  79. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/mlflow_model.py +0 -0
  80. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/table_client/__init__.py +0 -0
  81. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/table_client/table_client.py +0 -0
  82. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/__init__.py +0 -0
  83. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/client.py +0 -0
  84. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/__init__.py +0 -0
  85. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/store_config/__init__.py +0 -0
  86. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/store_config/redis.py +0 -0
  87. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/constants/__init__.py +0 -0
  88. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/constants/engine_types.py +0 -0
  89. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/feature_table_client/__init__.py +0 -0
  90. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/feature_table_client/feature_table_client.py +0 -0
  91. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/mlflow_model.py +0 -0
  92. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/training_set_client/__init__.py +0 -0
  93. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/training_set_client/training_set_client.py +0 -0
  94. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/__init__.py +0 -0
  95. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/interpol.py +0 -0
  96. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/intervals.py +0 -0
  97. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/io.py +0 -0
  98. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/ml.py +0 -0
  99. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/resample.py +0 -0
  100. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/tsdf.py +0 -0
  101. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/utils.py +0 -0
  102. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/dependency_links.txt +0 -0
  103. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/requires.txt +0 -0
  104. {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/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.6.dev2
3
+ Version: 0.2.6.dev3
4
4
  Summary: Wedata Feature Engineering Library Development
5
5
  Home-page:
6
6
  Author: meahqian
@@ -0,0 +1,52 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ scripts/build_executor_deps.sh 的静态检查(实跑需要 Linux x86_64 + 网络,放在发布流程里做)。
4
+
5
+ 曾经的三个问题,任何一个都会让打出来的包在 Executor 上不可用:
6
+ 1. 包名写成 tencent-wedata-feature-engineering,与 setup.py 不一致,pip 装不到;
7
+ 2. --python-version 39 与 python_requires>=3.10 冲突,pip 拒装;
8
+ 3. tar 打了外层 deps 目录,按 #deps 解压后变成 ./deps/deps/wedata,PYTHONPATH=./deps 找不到。
9
+ """
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ SCRIPT = (ROOT / "scripts" / "build_executor_deps.sh").read_text(encoding="utf-8")
16
+
17
+
18
+ def _setup_value(key: str) -> str:
19
+ match = re.search(rf"{key}\s*=\s*['\"]([^'\"]+)['\"]", (ROOT / "setup.py").read_text(encoding="utf-8"))
20
+ assert match, f"setup.py 中找不到 {key}"
21
+ return match.group(1)
22
+
23
+
24
+ def test_package_name_matches_setup_py():
25
+ assert f'SDK_PACKAGE="{_setup_value("name")}"' in SCRIPT
26
+ assert "tencent-wedata-feature-engineering" not in SCRIPT
27
+
28
+
29
+ def test_default_sdk_version_matches_constants():
30
+ from wedata.common.constants.constants import FEATURE_LOOKUP_CLIENT_MAJOR_VERSION
31
+
32
+ assert f'SDK_VERSION="${{SDK_VERSION:-{FEATURE_LOOKUP_CLIENT_MAJOR_VERSION}}}"' in SCRIPT
33
+
34
+
35
+ def test_target_python_satisfies_python_requires():
36
+ required = _setup_value("python_requires")
37
+ minimum = tuple(int(x) for x in required.lstrip(">=").split("."))
38
+ default = re.search(r'PY_VERSION="\$\{PY_VERSION:-([\d.]+)\}"', SCRIPT).group(1)
39
+ assert tuple(int(x) for x in default.split(".")) >= minimum
40
+ assert '--python-version "$PY_VERSION"' in SCRIPT
41
+
42
+
43
+ def test_archive_root_is_package_dir():
44
+ """归档里不能再套一层 deps:Spark 按 #deps 解压,PYTHONPATH=./deps 下要直接看到 wedata / msgpack。"""
45
+ assert 'tar -czf "${OUT_DIR}/${ARCHIVE_NAME}" -C "$STAGE_DIR/deps" .' in SCRIPT
46
+ assert not re.search(r'-C "\$STAGE_DIR" deps', SCRIPT)
47
+
48
+
49
+ def test_no_invalid_spark_conf_and_no_shasum():
50
+ code = "\n".join(line for line in SCRIPT.splitlines() if not line.lstrip().startswith("#"))
51
+ assert "spark.driverEnv" not in code # 不是 Spark 配置项
52
+ assert not re.search(r"\b(shasum|sha256sum)\s", code) # slim 镜像不一定有
@@ -101,6 +101,146 @@ class TestPrecheck:
101
101
  assert not client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
102
102
 
103
103
 
104
+ # ---------------------------------------------------------------------------
105
+ # 作业失败收尾
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _state(client, member, prefix=PREFIX):
109
+ return pc.decode_batch_info(client.hget(pc.versions_key(prefix), member))[pc.BATCH_STATE]
110
+
111
+
112
+ class TestRejectBatch:
113
+ def test_writing_rejected_and_purged_so_next_sync_is_not_blocked(self):
114
+ """作业失败后立即收尾:下一次同步的门禁不用等 MAX_RUN_HOURS。"""
115
+ client = make_client()
116
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING)
117
+
118
+ assert cp.reject_batch(client, PREFIX, member, "row count mismatch") is True
119
+
120
+ assert _state(client, member) == pc.STATE_PURGED
121
+ assert not client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
122
+ assert pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))["reason"] == "row count mismatch"
123
+ cp.precheck_and_purge(client, PREFIX)
124
+
125
+ def test_stale_batch_purged_immediately(self):
126
+ client = make_client()
127
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_STALE)
128
+
129
+ assert cp.reject_batch(client, PREFIX, member, "activate refused: STALE") is True
130
+ assert _state(client, member) == pc.STATE_PURGED
131
+ assert not client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u2"))
132
+
133
+ @pytest.mark.parametrize("state", [pc.STATE_ACTIVE, pc.STATE_RETIRED, pc.STATE_PURGED])
134
+ def test_activated_or_finished_batch_untouched(self, state):
135
+ """激活脚本已执行、只是客户端没收到回包:批次已是 ACTIVE,绝不能被删。"""
136
+ client = make_client()
137
+ member = register(client, PREFIX, sv(1), dv(1), state)
138
+
139
+ assert cp.reject_batch(client, PREFIX, member, "activate failed") is False
140
+ assert _state(client, member) == state
141
+ assert client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
142
+
143
+ def test_unknown_batch_is_noop(self):
144
+ client = make_client()
145
+ assert cp.reject_batch(client, PREFIX, pc.build_batch_member(sv(9), dv(9)), "x") is False
146
+ assert all_keys(client) == []
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # 长时间同步:心跳 / 原子判超时 / 只激活 WRITING
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def _activate(client, member, now=None):
154
+ sw, _prev, reason = client.eval(
155
+ pc.ACTIVATE_LUA, 2, pc.active_key(PREFIX), pc.versions_key(PREFIX), member,
156
+ int(now if now is not None else time.time()),
157
+ )
158
+ return int(sw), reason.decode() if isinstance(reason, bytes) else reason
159
+
160
+
161
+ class TestLongRunningSync:
162
+ def test_heartbeat_keeps_long_run_from_being_abandoned(self):
163
+ """超过 MAX_RUN_HOURS 的同步只要心跳在续,就不会被另一次运行判超时删掉。"""
164
+ client = make_client()
165
+ stale = int(time.time()) - 7 * 3600
166
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, updated_at=stale)
167
+
168
+ hb = cp.BatchHeartbeat(client, PREFIX, member)
169
+ assert hb.beat() is True
170
+
171
+ with pytest.raises(cp.PrecheckError, match="in progress"):
172
+ cp.precheck_and_purge(client, PREFIX)
173
+ assert client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
174
+
175
+ def test_heartbeat_thread_refreshes_and_stops(self):
176
+ client = make_client()
177
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, updated_at=1)
178
+
179
+ hb = cp.start_heartbeat(client, PREFIX, member, interval_seconds=0.01)
180
+ deadline = time.time() + 2
181
+ while time.time() < deadline and pc.decode_batch_info(
182
+ client.hget(pc.versions_key(PREFIX), member))[pc.BATCH_UPDATED_AT] == 1:
183
+ time.sleep(0.01)
184
+ hb.stop()
185
+
186
+ assert pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))[pc.BATCH_UPDATED_AT] > 1
187
+ assert not hb._thread.is_alive()
188
+
189
+ def test_heartbeat_does_not_revive_finished_batch(self):
190
+ client = make_client()
191
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_ACTIVE, updated_at=1)
192
+ assert cp.BatchHeartbeat(client, PREFIX, member).beat() is False
193
+ assert pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))[pc.BATCH_UPDATED_AT] == 1
194
+
195
+ def test_abandoned_marking_is_atomic_against_activation(self):
196
+ """判超时之后、改状态之前批次被激活:不能再标 ABANDONED,更不能删数据。"""
197
+ client = make_client()
198
+ member = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, updated_at=int(time.time()) - 7 * 3600)
199
+ seen = pc.decode_batch_info(client.hget(pc.versions_key(PREFIX), member))
200
+ assert _activate(client, member)[0] == 1 # 慢作业恰好在此刻激活
201
+
202
+ assert cp._mark_abandoned(client, PREFIX, member, seen, time.time()) is False
203
+ assert cp.cleanup(client, PREFIX) == 0
204
+ assert _state(client, member) == pc.STATE_ACTIVE
205
+ assert client.exists(pc.data_key(PREFIX, sv(1), dv(1), "u1"))
206
+
207
+ def test_abandoned_batch_can_no_longer_be_activated(self):
208
+ """被判超时清理后,慢作业的激活必须被拒绝,__active__ 不能指向已删除的批次。"""
209
+ client = make_client()
210
+ cur = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING)
211
+ assert _activate(client, cur)[0] == 1
212
+ slow = register(client, PREFIX, sv(1), dv(2), pc.STATE_WRITING, watermark=5000,
213
+ updated_at=int(time.time()) - 7 * 3600)
214
+
215
+ cp.cleanup(client, PREFIX)
216
+ assert _state(client, slow) == pc.STATE_PURGED
217
+
218
+ assert _activate(client, slow) == (0, "NOT_WRITING")
219
+ assert client.get(pc.active_key(PREFIX)).decode() == cur
220
+
221
+
222
+ class TestFutureWatermark:
223
+ def test_dirty_future_watermark_does_not_block_later_batches(self):
224
+ client = make_client()
225
+ now = int(time.time())
226
+ dirty = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, watermark=now + 30 * 86400)
227
+ assert _activate(client, dirty, now)[0] == 1
228
+ fixed = register(client, PREFIX, sv(1), dv(2), pc.STATE_WRITING, watermark=now - 60)
229
+
230
+ assert _activate(client, fixed, now)[0] == 1
231
+ assert client.get(pc.active_key(PREFIX)).decode() == fixed
232
+
233
+ def test_watermark_within_tolerance_still_compared(self):
234
+ """时区错配(8 小时内)的水位仍是有效水位,旧数据不能覆盖它。"""
235
+ client = make_client()
236
+ now = int(time.time())
237
+ cur = register(client, PREFIX, sv(1), dv(1), pc.STATE_WRITING, watermark=now + 8 * 3600)
238
+ assert _activate(client, cur, now)[0] == 1
239
+ older = register(client, PREFIX, sv(1), dv(2), pc.STATE_WRITING, watermark=now - 60)
240
+
241
+ assert _activate(client, older, now) == (0, "STALE")
242
+
243
+
104
244
  # ---------------------------------------------------------------------------
105
245
  # 状态规则
106
246
  # ---------------------------------------------------------------------------
@@ -167,6 +167,51 @@ def test_length_tolerance():
167
167
  assert pc.decode_row(meta, long)[1] == {"age": 1, "city_id": 2, "ctr_7d": 3.0, "name": "n"}
168
168
 
169
169
 
170
+ def test_decimal_and_date_columns_encode_by_declared_type():
171
+ """Spark DecimalType / DateType 的值是 Decimal / date;Java 把它们映射为 STRING,msgpack 不能直接序列化。"""
172
+ meta = make_meta(
173
+ fields=(
174
+ ("amount", "STRING"),
175
+ ("rate", "DOUBLE"),
176
+ ("cnt", "INT64"),
177
+ ("birthday", "STRING"),
178
+ ("signup", "UNIX_TIMESTAMP"),
179
+ ("seen_at", "STRING"),
180
+ )
181
+ )
182
+ values = {
183
+ "amount": Decimal("12.30"),
184
+ "rate": Decimal("0.125"),
185
+ "cnt": Decimal("42"),
186
+ "birthday": dt.date(1990, 5, 17),
187
+ "signup": dt.date(2026, 1, 2),
188
+ "seen_at": dt.datetime(2026, 1, 2, 3, 4, 5),
189
+ }
190
+ _, out = pc.decode_row(meta, pc.encode_row(meta, 1, values))
191
+ assert out == {
192
+ "amount": "12.30",
193
+ "rate": 0.125,
194
+ "cnt": 42,
195
+ "birthday": "1990-05-17",
196
+ "signup": int(dt.datetime(2026, 1, 2, tzinfo=dt.timezone.utc).timestamp()),
197
+ "seen_at": "2026-01-02T03:04:05",
198
+ }
199
+ # 不出现科学计数法;NaN Decimal 按缺失处理
200
+ assert pc.decode_row(meta, pc.encode_row(meta, 1, {"amount": Decimal("1E+2"), "rate": Decimal("NaN")}))[1] == {
201
+ "amount": "100"
202
+ }
203
+
204
+
205
+ def test_unencodable_value_reports_column():
206
+ meta = make_meta(fields=(("cnt", "INT64"), ("flag", "BOOL"), ("tags", "STRING")))
207
+ with pytest.raises(PackedCodecError, match="cnt"):
208
+ pc.encode_row(meta, 1, {"cnt": Decimal("1.5")})
209
+ with pytest.raises(PackedCodecError, match="flag"):
210
+ pc.encode_row(meta, 1, {"flag": dt.date(2026, 1, 1)})
211
+ with pytest.raises(PackedCodecError, match="tags"):
212
+ pc.encode_row(meta, 1, {"tags": object()})
213
+
214
+
170
215
  def test_meta_roundtrip_and_codec_version_guard():
171
216
  meta = make_meta()
172
217
  assert SchemaMeta.from_bytes(meta.to_bytes()) == meta
@@ -57,6 +57,12 @@ class FakeFVNoTag(FakeFV):
57
57
  tags = {}
58
58
 
59
59
 
60
+ class FakeCompositeFV(FakeFV):
61
+ """复合主键:meta.entity_keys = (member_id, dept_id)"""
62
+
63
+ entity_columns = [_F("member_id", "STRING"), _F("dept_id", "STRING")]
64
+
65
+
60
66
  def make_store():
61
67
  store = WedataRedisPackedOnlineStore()
62
68
  store._client = fakeredis.FakeRedis()
@@ -219,6 +225,49 @@ class TestWriteReadRoundtrip:
219
225
  with pytest.raises(pc.PackedCodecError, match="join_keys"):
220
226
  store.online_read(config, fv, [wrong])
221
227
 
228
+ def test_composite_key_order_independent(self):
229
+ """F8 只拦集合不同:复合主键请求写成 {dept, member}(feast 按请求键序构造 join_keys)仍能读到。"""
230
+ store, config = make_store()
231
+ fv = FakeCompositeFV()
232
+ written = EntityKeyProto(
233
+ join_keys=["member_id", "dept_id"],
234
+ entity_values=[ValueProto(string_val="10086"), ValueProto(string_val="SH001")],
235
+ )
236
+ store.online_write_batch(config, fv, [(written, row(25, "北京", 3.5), ts(), None)])
237
+
238
+ reversed_order = EntityKeyProto(
239
+ join_keys=["dept_id", "member_id"],
240
+ entity_values=[ValueProto(string_val="SH001"), ValueProto(string_val="10086")],
241
+ )
242
+ [(_, values)] = store.online_read(config, fv, [reversed_order])
243
+ assert values["age"].int64_val == 25
244
+
245
+ partial = EntityKeyProto(join_keys=["member_id"], entity_values=[ValueProto(string_val="10086")])
246
+ with pytest.raises(pc.PackedCodecError, match="join_keys"):
247
+ store.online_read(config, fv, [partial])
248
+ duplicated = EntityKeyProto(
249
+ join_keys=["member_id", "member_id"],
250
+ entity_values=[ValueProto(string_val="10086"), ValueProto(string_val="10086")],
251
+ )
252
+ with pytest.raises(pc.PackedCodecError, match="join_keys"):
253
+ store.online_read(config, fv, [duplicated])
254
+
255
+ def test_composite_key_written_in_other_order_lands_on_same_key(self):
256
+ store, config = make_store()
257
+ fv = FakeCompositeFV()
258
+ reversed_order = EntityKeyProto(
259
+ join_keys=["dept_id", "member_id"],
260
+ entity_values=[ValueProto(string_val="SH001"), ValueProto(string_val="10086")],
261
+ )
262
+ store.online_write_batch(config, fv, [(reversed_order, row(30, "上海", 4.0), ts(), None)])
263
+
264
+ written = EntityKeyProto(
265
+ join_keys=["member_id", "dept_id"],
266
+ entity_values=[ValueProto(string_val="10086"), ValueProto(string_val="SH001")],
267
+ )
268
+ [(_, values)] = store.online_read(config, fv, [written])
269
+ assert values["age"].int64_val == 30
270
+
222
271
  def test_roundtrip(self):
223
272
  store, config = make_store()
224
273
  fv = FakeFV()
@@ -24,6 +24,11 @@ _TEMPLATE_RELPATH = os.path.join(
24
24
  "resources", "templates", "offline_to_online_3.0_redis.ftl",
25
25
  )
26
26
 
27
+ _FEATURE_SERVICE_JAVA_RELPATH = os.path.join(
28
+ "..", "..", "..", "science", "server", "sci-feature-service", "src", "main", "java",
29
+ "com", "tencent", "cloud", "wedata", "feature", "service", "FeatureService.java",
30
+ )
31
+
27
32
  #: Java genScriptContentRedis 必须提供的全部 FreeMarker 变量(两侧契约,改动需同步)
28
33
  EXPECTED_VARS = {
29
34
  "WORKSPACE_ID", "FEAST_SERVICE_IP", "FEAST_SERVICE_PORT",
@@ -42,7 +47,7 @@ _SAMPLE_VALUES = {
42
47
  "FEATURE_VIEW_NAME": "member_feat",
43
48
  "REDIS_CONNECTION_STRING": "redis://wedata:Wedata%402024@10.1.2.3:6379/0",
44
49
  "ONLINE_TABLE_PREFIX": "1001:k3j5h7g9d2s4",
45
- "SDK_VERSION": "0.2.6.dev2",
50
+ "SDK_VERSION": "0.2.6.dev3",
46
51
  "PARTITIONS": "256",
47
52
  "TTL_SECONDS": "2592000",
48
53
  "VERIFY_SAMPLE_SIZE": "100",
@@ -139,6 +144,18 @@ class TestTemplateStructure:
139
144
  # 包名必须与 setup.py 一致(wedata3-feature-engineering),版本为 Java 注入
140
145
  assert "wedata3-feature-engineering==" in first
141
146
 
147
+ def test_java_default_sdk_version_matches_package(self):
148
+ """Java 渲染模板时 SDK_VERSION 的默认值必须是当前包版本:模板调用的 cleanup_packed.reject_batch 等只在新版本里有。"""
149
+ if _template_path() is None:
150
+ pytest.skip("template not found (science Java module not checked out)")
151
+ from wedata.common.constants.constants import FEATURE_LOOKUP_CLIENT_MAJOR_VERSION
152
+
153
+ java = os.path.abspath(os.path.join(os.path.dirname(__file__), _FEATURE_SERVICE_JAVA_RELPATH))
154
+ with open(java, encoding="utf-8") as f:
155
+ match = re.search(r"\$\{feature\.redis-sync\.sdk-version:([^}]+)\}", f.read())
156
+ assert match, "FeatureService.java 里找不到 feature.redis-sync.sdk-version 默认值"
157
+ assert match.group(1) == FEATURE_LOOKUP_CLIENT_MAJOR_VERSION
158
+
142
159
 
143
160
  class TestTemplateCorrectnessContracts:
144
161
  """红线契约:模板必须复用单一事实来源,禁止手拼。"""
@@ -181,6 +198,35 @@ class TestTemplateCorrectnessContracts:
181
198
  assert "STATE_WRITING" in all_src, "batch must be registered before writing"
182
199
  assert "cleanup" in all_src
183
200
 
201
+ def test_failures_after_register_reject_the_batch(self):
202
+ """登记批次之后的每一步失败都要 reject_batch 收尾,否则批次停在 WRITING,下一次同步被锁 6 小时。"""
203
+ _, nb = _rendered_nb()
204
+ cells = {c.get("id"): "".join(c["source"]) for c in _code_cells(nb)}
205
+ assert "cp.reject_batch(redis_client, online_table_prefix, batch_member" in cells["redis-register-batch"]
206
+ for cell_id in ("redis-spark-write", "redis-verify", "redis-activate"):
207
+ assert "_fail_batch(" in cells[cell_id], f"{cell_id} must reject the batch on failure"
208
+ assert "left in WRITING" not in "\n".join(cells.values())
209
+
210
+ def test_heartbeat_covers_writing_phase(self):
211
+ """登记后开始心跳,失败收尾与激活前都要停:否则运行超过 6 小时会被判超时删数据,或心跳续住一个死批次。"""
212
+ _, nb = _rendered_nb()
213
+ cells = {c.get("id"): "".join(c["source"]) for c in _code_cells(nb)}
214
+ register = cells["redis-register-batch"]
215
+ assert register.index("redis_client.hset(") < register.index("cp.start_heartbeat(")
216
+ fail_batch = register[register.index("def _fail_batch"):]
217
+ assert fail_batch.index("_heartbeat.stop()") < fail_batch.index("cp.reject_batch(")
218
+ activate = cells["redis-activate"]
219
+ assert activate.index("_heartbeat.stop()") < activate.index("pc.ACTIVATE_LUA")
220
+
221
+ def test_dirty_data_guards(self):
222
+ """未来时间戳截断水位(否则之后所有批次都被判旧);主键 NULL 先过滤(否则写到一半整批失败)。"""
223
+ _, nb = _rendered_nb()
224
+ cells = {c.get("id"): "".join(c["source"]) for c in _code_cells(nb)}
225
+ assert "pc.WATERMARK_FUTURE_TOLERANCE_SECONDS" in cells["redis-register-batch"]
226
+ load = cells["redis-load-dedupe"]
227
+ assert "isNull()" in load and "F.isnan(" in load
228
+ assert load.index("df.filter(~_null_key)") < load.index("expected_count = df.count()")
229
+
184
230
  def test_meta_from_fv_not_schema(self):
185
231
  _, nb = _rendered_nb()
186
232
  all_src = "\n".join("".join(c["source"]) for c in _code_cells(nb))
@@ -46,7 +46,7 @@ FEATURE_LOOKUP_CLIENT_PIP_PACKAGE = "wedata3-feature-engineering"
46
46
  # 特征查找版本号
47
47
  # FEATURE_LOOKUP_CLIENT_MAJOR_VERSION = "0.6.16"
48
48
  # FEATURE_LOOKUP_CLIENT_PIP_PACKAGE_VERSION = f"{FEATURE_LOOKUP_CLIENT_PIP_PACKAGE}>=0.6.16"
49
- FEATURE_LOOKUP_CLIENT_MAJOR_VERSION = "0.2.6.dev2"
49
+ FEATURE_LOOKUP_CLIENT_MAJOR_VERSION = "0.2.6.dev3"
50
50
  FEATURE_LOOKUP_CLIENT_PIP_PACKAGE_VERSION = f"{FEATURE_LOOKUP_CLIENT_PIP_PACKAGE}>=0.2.4"
51
51
 
52
52
  # 特征存储内部数据目录
@@ -389,19 +389,58 @@ def _encode_value(vtype: str, v: Any) -> Any:
389
389
  return None
390
390
  if isinstance(v, float) and math.isnan(v):
391
391
  return None
392
- if vtype == "UNIX_TIMESTAMP" and isinstance(v, _dt.datetime):
392
+ if isinstance(v, Decimal):
393
+ return _encode_decimal(vtype, v)
394
+ if isinstance(v, _dt.date):
395
+ return _encode_temporal(vtype, v)
396
+ return v
397
+
398
+
399
+ def _encode_decimal(vtype: str, v: Decimal) -> Any:
400
+ """Spark DecimalType → Decimal。Java 侧把 decimal 映射为 STRING(未知类型降级),msgpack 不能直接序列化 Decimal。"""
401
+ if not v.is_finite():
402
+ return None
403
+ if vtype == "STRING":
404
+ return format(v, "f") # 保留小数位、不出现科学计数法(Decimal('1E+2') → '100')
405
+ if vtype in ("DOUBLE", "FLOAT"):
406
+ return float(v)
407
+ if vtype in ("INT32", "INT64"):
408
+ if v != v.to_integral_value():
409
+ raise PackedCodecError(f"小数 {v} 不能写入整数类型 {vtype}")
410
+ return int(v)
411
+ raise PackedCodecError(f"Decimal 不能写入 {vtype} 类型")
412
+
413
+
414
+ def _encode_temporal(vtype: str, v: _dt.date) -> Any:
415
+ """datetime / date。UNIX_TIMESTAMP 存 epoch 秒;Java 把 date 映射为 STRING,按 ISO 格式存。"""
416
+ if vtype == "UNIX_TIMESTAMP":
417
+ if not isinstance(v, _dt.datetime):
418
+ v = _dt.datetime(v.year, v.month, v.day) # date 按当天 0 点
393
419
  if v.tzinfo is None:
394
420
  v = v.replace(tzinfo=_dt.timezone.utc)
395
421
  return int(v.timestamp())
396
- return v
422
+ if vtype == "STRING":
423
+ return v.isoformat()
424
+ raise PackedCodecError(f"{type(v).__name__} 不能写入 {vtype} 类型")
397
425
 
398
426
 
399
427
  def encode_row(meta: SchemaMeta, ts_micros: Optional[int], values: Mapping[str, Any]) -> bytes:
400
428
  """dict → 位置数组 msgpack;缺失字段编码为 nil。下标 0 固定为 event_ts_micros。"""
401
429
  arr: List[Any] = [None if ts_micros is None else int(ts_micros)]
402
430
  for name, vtype in meta.fields:
403
- arr.append(_encode_value(vtype, values.get(name)))
404
- return msgpack.packb(arr, use_bin_type=True)
431
+ try:
432
+ arr.append(_encode_value(vtype, values.get(name)))
433
+ except PackedCodecError as e:
434
+ raise PackedCodecError(f"特征 {name} 编码失败: {e}") from None
435
+ try:
436
+ return msgpack.packb(arr, use_bin_type=True)
437
+ except TypeError as e:
438
+ # 兜底:出现 codec 没覆盖的类型时报清楚是哪一列,而不是裸 msgpack 错误
439
+ bad = [n for (n, _), x in zip(meta.fields, arr[1:]) if not isinstance(x, _MSGPACK_SCALARS)]
440
+ raise PackedCodecError(f"特征 {bad} 的值类型不支持序列化: {e}") from None
441
+
442
+
443
+ _MSGPACK_SCALARS = (type(None), bool, int, float, str, bytes, bytearray)
405
444
 
406
445
 
407
446
  def decode_row(
@@ -617,20 +656,35 @@ ACTIVATE_LUA = r"""
617
656
  -- 返回 {switched(1/0), previous_active or "", reason}
618
657
  local new_member = ARGV[1]
619
658
  local now = tonumber(ARGV[2])
659
+ -- 水位超过 now + 1 天视为脏数据(未来时间戳),按 0 参与比较:
660
+ -- 否则一条脏数据会让之后所有批次都被判旧(与 WATERMARK_FUTURE_TOLERANCE_SECONDS 一致)
661
+ local MAX_FUTURE_SECONDS = 86400
662
+ local function watermark_of(info)
663
+ if info.watermark > now + MAX_FUTURE_SECONDS then
664
+ return 0
665
+ end
666
+ return info.watermark
667
+ end
620
668
 
621
669
  local new_raw = redis.call('HGET', KEYS[2], new_member)
622
670
  if not new_raw then
623
671
  return {0, '', 'NEW_NOT_REGISTERED'}
624
672
  end
625
673
  local new_info = cjson.decode(new_raw)
674
+ -- 只激活写入中的批次:超时被判 ABANDONED、已 REJECTED / PURGED 的批次数据可能已被清理,激活即全量 miss
675
+ if new_info.state ~= 'WRITING' then
676
+ return {0, redis.call('GET', KEYS[1]) or '', 'NOT_WRITING'}
677
+ end
626
678
 
627
679
  local cur_member = redis.call('GET', KEYS[1])
628
680
  if cur_member then
629
681
  local cur_raw = redis.call('HGET', KEYS[2], cur_member)
630
682
  if cur_raw then
631
683
  local cur = cjson.decode(cur_raw)
632
- local newer = (new_info.watermark > cur.watermark) or
633
- (new_info.watermark == cur.watermark and new_info.run_start > cur.run_start)
684
+ local new_wm = watermark_of(new_info)
685
+ local cur_wm = watermark_of(cur)
686
+ local newer = (new_wm > cur_wm) or
687
+ (new_wm == cur_wm and new_info.run_start > cur.run_start)
634
688
  if not newer then
635
689
  new_info.state = 'STALE'
636
690
  new_info.updated_at = now
@@ -651,3 +705,7 @@ new_info.updated_at = now
651
705
  redis.call('HSET', KEYS[2], new_member, cjson.encode(new_info))
652
706
  return {1, cur_member or '', 'SWITCHED'}
653
707
  """
708
+
709
+ #: 批次水位允许超出当前时间的上限(秒)。同步模板登记时超出即截断为当前时间并告警,ACTIVATE_LUA 比较时超出按 0 处理。
710
+ #: 1 天能容纳时区错配(P1-13 的 8 小时偏移)与机器时钟偏差。
711
+ WATERMARK_FUTURE_TOLERANCE_SECONDS = 86400
@@ -15,6 +15,7 @@ key 格式与 BatchInfo 编解码统一走 ``wedata.common.online_store.packed_c
15
15
  | 状态 | 处置 |
16
16
  |---|---|
17
17
  | WRITING(未超时 <6h) | 不清理(同步进行中) |
18
+ | WRITING(作业失败收尾)| ``reject_batch`` 置 REJECTED 并立即清理,不等 6h |
18
19
  | WRITING(超时 ≥6h)| 转 ABANDONED 后立即清理 |
19
20
  | ACTIVE | **永不清理**(二次确认防误删) |
20
21
  | RETIRED(最新 1 个,retired <1h) | 保留(回滚窗口) |
@@ -30,6 +31,7 @@ from __future__ import annotations
30
31
 
31
32
  import logging
32
33
  import re
34
+ import threading
33
35
  import time
34
36
  from typing import Any, Dict, List, Optional, Tuple
35
37
 
@@ -134,14 +136,15 @@ def cleanup(client: redis.Redis, prefix: str) -> int:
134
136
  if state in (pc.STATE_REJECTED, pc.STATE_STALE, pc.STATE_ABANDONED):
135
137
  to_delete.append((member, info))
136
138
  elif state == pc.STATE_WRITING and _is_abandoned(info, now):
137
- _mark_state(client, prefix, member, pc.STATE_ABANDONED, now)
138
- to_delete.append((member, info))
139
+ # 原子判定:期间批次被激活或续了心跳就不动它(快照判超时、再非原子改状态会把刚激活的批次删掉)
140
+ if _mark_abandoned(client, prefix, member, info, now):
141
+ to_delete.append((member, info))
139
142
  # ACTIVE / 未超时 WRITING / PURGED:不删数据
140
143
 
141
144
  deleted = 0
142
145
  for member, _info in to_delete:
143
- if member == active_member:
144
- # 防御性检查:理论不可能(ACTIVE 不在 to_delete),双重保险
146
+ # active 以删除前实时读取为准,不用上面的快照:快照之后可能已切换
147
+ if member in (active_member, _get_active_member(client, prefix)):
145
148
  logger.warning("skip purge of active batch %r on %r", member, prefix)
146
149
  continue
147
150
  try:
@@ -170,6 +173,149 @@ def purge_batch(client: redis.Redis, prefix: str, member: str) -> None:
170
173
  _mark_state(client, prefix, member, pc.STATE_PURGED, time.time())
171
174
 
172
175
 
176
+ #: 只把仍处于 WRITING 的批次改为 REJECTED,返回改写后的状态(批次不存在返回空串)。
177
+ #: 原子判断:激活脚本已执行但客户端没收到回包时,批次可能已是 ACTIVE,绝不能被改写。
178
+ _REJECT_LUA = r"""
179
+ local raw = redis.call('HGET', KEYS[1], ARGV[1])
180
+ if not raw then
181
+ return ''
182
+ end
183
+ local info = cjson.decode(raw)
184
+ if info.state == 'WRITING' then
185
+ info.state = 'REJECTED'
186
+ info.updated_at = tonumber(ARGV[2])
187
+ info.reason = ARGV[3]
188
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(info))
189
+ end
190
+ return info.state
191
+ """
192
+
193
+ #: 失败收尾时可以立即删除数据的状态(REJECTED 来自本函数,STALE 来自激活脚本判旧)
194
+ _DISCARDABLE_STATES = frozenset({pc.STATE_REJECTED, pc.STATE_STALE})
195
+
196
+
197
+ def reject_batch(client: redis.Redis, prefix: str, member: str, reason: str) -> bool:
198
+ """
199
+ 同步作业失败收尾:本批 WRITING → REJECTED,并立即删除本批数据,返回是否删除了数据。
200
+
201
+ 不收尾的话批次停在 WRITING,下一次同步的门禁要等 ``MAX_RUN_HOURS`` 才放行;
202
+ 激活被判旧(STALE)的批次同样立即删除,不留一份全量数据占内存到下一次同步。
203
+ 已激活(ACTIVE / RETIRED)或已清理的批次不动。
204
+ """
205
+ state = client.eval(
206
+ _REJECT_LUA, 1, pc.versions_key(prefix), member, int(time.time()), (reason or "")[:200]
207
+ )
208
+ state = state.decode() if isinstance(state, bytes) else state
209
+ if state not in _DISCARDABLE_STATES:
210
+ logger.warning("skip rejecting batch %r on %r: state=%r", member, prefix, state)
211
+ return False
212
+ purge_batch(client, prefix, member)
213
+ logger.info("rejected and purged batch %r on %r: %s", member, prefix, reason)
214
+ return True
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # 写入心跳(长时间同步不被判超时)
219
+ # ---------------------------------------------------------------------------
220
+
221
+ #: 心跳间隔,远小于 MAX_RUN_HOURS:Driver 卡顿几次也不至于被判超时
222
+ HEARTBEAT_INTERVAL_SECONDS = 300
223
+
224
+ #: 心跳最长持续时间:超过即停止续期,交给超时清理,防止失控的 Driver 永久占住门禁
225
+ HEARTBEAT_MAX_SECONDS = 24 * 3600
226
+
227
+ #: 只给仍是 WRITING 的批次刷新 updated_at,返回 1 / 0
228
+ _HEARTBEAT_LUA = r"""
229
+ local raw = redis.call('HGET', KEYS[1], ARGV[1])
230
+ if not raw then
231
+ return 0
232
+ end
233
+ local info = cjson.decode(raw)
234
+ if info.state ~= 'WRITING' then
235
+ return 0
236
+ end
237
+ info.updated_at = tonumber(ARGV[2])
238
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(info))
239
+ return 1
240
+ """
241
+
242
+ #: 仍是 WRITING 且 updated_at 与判超时时看到的一致(期间没被激活、没续心跳)才改为 ABANDONED,返回 1 / 0
243
+ _MARK_ABANDONED_LUA = r"""
244
+ local raw = redis.call('HGET', KEYS[1], ARGV[1])
245
+ if not raw then
246
+ return 0
247
+ end
248
+ local info = cjson.decode(raw)
249
+ if info.state ~= 'WRITING' or (tonumber(info.updated_at) or 0) ~= tonumber(ARGV[2]) then
250
+ return 0
251
+ end
252
+ info.state = 'ABANDONED'
253
+ info.updated_at = tonumber(ARGV[3])
254
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(info))
255
+ return 1
256
+ """
257
+
258
+
259
+ class BatchHeartbeat:
260
+ """
261
+ 写入期间在后台线程里定期刷新批次 ``updated_at``。
262
+
263
+ 不刷新的话,超过 ``MAX_RUN_HOURS`` 的同步会被下一次运行的门禁判为 ABANDONED 并删掉数据,
264
+ 而本次运行随后仍可能激活它,``__active__`` 指向已删除的批次,读侧全量 miss。
265
+ 批次一旦不再是 WRITING(已激活 / 被拒绝 / 被判超时),心跳自动结束。
266
+ """
267
+
268
+ def __init__(
269
+ self,
270
+ client: redis.Redis,
271
+ prefix: str,
272
+ member: str,
273
+ interval_seconds: float = HEARTBEAT_INTERVAL_SECONDS,
274
+ max_seconds: float = HEARTBEAT_MAX_SECONDS,
275
+ ) -> None:
276
+ self._client = client
277
+ self._prefix = prefix
278
+ self._member = member
279
+ self._interval = interval_seconds
280
+ self._max_seconds = max_seconds
281
+ self._stop = threading.Event()
282
+ self._thread = threading.Thread(target=self._run, name=f"batch-heartbeat-{member}", daemon=True)
283
+
284
+ def start(self) -> "BatchHeartbeat":
285
+ self._thread.start()
286
+ return self
287
+
288
+ def beat(self) -> bool:
289
+ """刷新一次,返回批次是否仍在写入中。"""
290
+ return bool(
291
+ self._client.eval(_HEARTBEAT_LUA, 1, pc.versions_key(self._prefix), self._member, int(time.time()))
292
+ )
293
+
294
+ def stop(self) -> None:
295
+ """停止心跳(幂等)。"""
296
+ self._stop.set()
297
+ if self._thread.is_alive() and self._thread is not threading.current_thread():
298
+ self._thread.join(timeout=5)
299
+
300
+ def _run(self) -> None:
301
+ deadline = time.monotonic() + self._max_seconds
302
+ while not self._stop.wait(self._interval):
303
+ if time.monotonic() > deadline:
304
+ logger.warning("heartbeat of batch %r on %r exceeded %ss, stop", self._member, self._prefix,
305
+ self._max_seconds)
306
+ return
307
+ try:
308
+ if not self.beat():
309
+ return
310
+ except Exception as exc: # 单次失败不退出:下一轮再试,超时判定有 MAX_RUN_HOURS 的余量
311
+ logger.warning("heartbeat of batch %r on %r failed: %s", self._member, self._prefix, exc)
312
+
313
+
314
+ def start_heartbeat(client: redis.Redis, prefix: str, member: str, **kwargs: Any) -> BatchHeartbeat:
315
+ """登记批次后立即调用;激活或失败收尾前调用返回值的 ``stop()``。"""
316
+ return BatchHeartbeat(client, prefix, member, **kwargs).start()
317
+
318
+
173
319
  # ---------------------------------------------------------------------------
174
320
  # 整表删除(删除在线表的一次性清理作业 / SDK delete_table 共用)
175
321
  # ---------------------------------------------------------------------------
@@ -273,6 +419,16 @@ def _is_abandoned(info: Dict[str, Any], now: float) -> bool:
273
419
  return (now - updated) > MAX_RUN_HOURS * 3600
274
420
 
275
421
 
422
+ def _mark_abandoned(client: redis.Redis, prefix: str, member: str, seen: Dict[str, Any], now: float) -> bool:
423
+ """超时的 WRITING → ABANDONED(原子比较 updated_at),返回是否标记成功。"""
424
+ return bool(
425
+ client.eval(
426
+ _MARK_ABANDONED_LUA, 1, pc.versions_key(prefix), member,
427
+ int(seen.get(pc.BATCH_UPDATED_AT, 0)), int(now),
428
+ )
429
+ )
430
+
431
+
276
432
  def _get_active_member(client: redis.Redis, prefix: str) -> Optional[str]:
277
433
  member = client.get(pc.active_key(prefix))
278
434
  if member is None:
@@ -179,15 +179,61 @@ class WedataRedisPackedOnlineStore(OnlineStore):
179
179
  # ------------------------------------------------------------------
180
180
 
181
181
  @staticmethod
182
- def serialize_entity_key(entity_key_proto: EntityKeyProto) -> str:
183
- """EntityKeyProto → 实体键 ek(按 join_keys 顺序取值,转义后用 ``|`` 连接,codec 设计 §2.6.3)。"""
182
+ def serialize_entity_key(entity_key_proto: EntityKeyProto, order: Optional[Sequence[int]] = None) -> str:
183
+ """
184
+ EntityKeyProto → 实体键 ek(转义后用 ``|`` 连接,codec 设计 §2.6.3)。
185
+
186
+ :param order: 按 ``meta.entity_keys`` 顺序取值的下标(见 :meth:`_entity_value_order`);
187
+ None 表示 join_keys 顺序与 meta 一致,按原顺序取值
188
+ """
184
189
  values = []
185
190
  for val in entity_key_proto.entity_values:
186
191
  if val.WhichOneof("val") is None:
187
192
  raise pc.PackedCodecError("entity value not set (None)")
188
193
  values.append(_proto_value_to_py(val))
194
+ if order is not None:
195
+ values = [values[i] for i in order]
189
196
  return pc.build_entity_key_from_values(values)
190
197
 
198
+ @staticmethod
199
+ def _entity_value_order(
200
+ join_keys: Sequence[str], meta: SchemaMeta, table_name: str, prefix: str
201
+ ) -> Optional[List[int]]:
202
+ """
203
+ F8:join_keys 与建表时的 ``meta.entity_keys`` 比对。
204
+
205
+ 集合相同、顺序不同(如请求写成 ``{"b": 1, "a": 2}``,feast 按请求键序构造 join_keys)是合法请求,
206
+ 返回按 meta 顺序取值的下标;顺序一致或未带 join_keys 返回 None;集合不同(registry 改名 / 传错 FV)抛错,
207
+ 否则会拼出错误的 ek 静默 100% miss。
208
+ """
209
+ if not join_keys:
210
+ return None
211
+ keys = tuple(join_keys)
212
+ expected = tuple(meta.entity_keys)
213
+ if keys == expected:
214
+ return None
215
+ if len(set(keys)) != len(keys) or set(keys) != set(expected):
216
+ raise pc.PackedCodecError(
217
+ f"entity join_keys {list(keys)} do not match online table "
218
+ f"{table_name} (meta entity_keys={list(expected)}, prefix={prefix!r}); "
219
+ f"the feature view / registry has drifted from the published table"
220
+ )
221
+ position = {k: i for i, k in enumerate(keys)}
222
+ return [position[k] for k in expected]
223
+
224
+ def _serialize_entity_keys(
225
+ self, entity_keys: Sequence[EntityKeyProto], meta: SchemaMeta, table_name: str, prefix: str
226
+ ) -> List[str]:
227
+ """按 meta 顺序批量构造 ek;同一批里 join_keys 顺序相同的只比对一次。"""
228
+ orders: Dict[Tuple[str, ...], Optional[List[int]]] = {}
229
+ out = []
230
+ for ek in entity_keys:
231
+ join_keys = tuple(ek.join_keys)
232
+ if join_keys not in orders:
233
+ orders[join_keys] = self._entity_value_order(join_keys, meta, table_name, prefix)
234
+ out.append(self.serialize_entity_key(ek, orders[join_keys]))
235
+ return out
236
+
191
237
  # ------------------------------------------------------------------
192
238
  # 读路径(稳态 1 次 MGET)
193
239
  # ------------------------------------------------------------------
@@ -259,16 +305,8 @@ class WedataRedisPackedOnlineStore(OnlineStore):
259
305
  sv, dv = pc.parse_active_pointer(member)
260
306
  meta = self._get_meta(client, prefix, sv)
261
307
 
262
- # F8:entity join_keys 与建表时的 meta.entity_keys 不一致(registry 重命名/重排/传错 FV)
263
- # → 抛错。否则按错误顺序拼 ek 会静默 100% miss。
264
- if entity_keys[0].join_keys:
265
- read_join_keys = tuple(entity_keys[0].join_keys)
266
- if read_join_keys != tuple(meta.entity_keys):
267
- raise pc.PackedCodecError(
268
- f"entity join_keys {list(read_join_keys)} do not match online table "
269
- f"{table.name} (meta entity_keys={list(meta.entity_keys)}, prefix={prefix!r}); "
270
- f"the feature view / registry has drifted from the published table"
271
- )
308
+ # F8:join_keys 与 meta.entity_keys 集合不同则抛错,顺序不同按 meta 顺序重排(见 _entity_value_order)
309
+ eks = self._serialize_entity_keys(entity_keys, meta, table.name, prefix)
272
310
 
273
311
  # 校验 requested_features ⊆ meta fields(防拼写错误静默丢列)
274
312
  if requested_features:
@@ -278,7 +316,7 @@ class WedataRedisPackedOnlineStore(OnlineStore):
278
316
  f"requested features not in online table {table.name}: {sorted(unknown)}"
279
317
  )
280
318
 
281
- keys = [pc.data_key(prefix, sv, dv, self.serialize_entity_key(ek)) for ek in entity_keys]
319
+ keys = [pc.data_key(prefix, sv, dv, ek) for ek in eks]
282
320
  blobs = client.mget(keys)
283
321
 
284
322
  result: List[Tuple[Optional[datetime], Optional[Dict[str, ValueProto]]]] = []
@@ -326,6 +364,8 @@ class WedataRedisPackedOnlineStore(OnlineStore):
326
364
  meta = pc.build_meta_from_feature_view(table, source_table=_source_table(table))
327
365
  ttl = int(getattr(config.online_store, "ttl_seconds", 86400 * 30))
328
366
  max_bytes = int(getattr(config.online_store, "max_string_bytes", pc.DEFAULT_MAX_STRING_BYTES))
367
+ # 与读路径同一规则按 meta 顺序构造 ek;放在登记批次之前,主键非法时不留下 WRITING 批次
368
+ eks = self._serialize_entity_keys([d[0] for d in data], meta, table.name, prefix)
329
369
 
330
370
  member = self._get_active(client, prefix)
331
371
  with self._cache_lock: # 写路径直接失效读缓存,保证写后读一致
@@ -350,14 +390,14 @@ class WedataRedisPackedOnlineStore(OnlineStore):
350
390
  member = pc.build_batch_member(sv, dv)
351
391
 
352
392
  pipe = client.pipeline(transaction=False)
353
- for entity_key_proto, feature_map, event_ts, _created_ts in data:
393
+ for ek, (_entity_key_proto, feature_map, event_ts, _created_ts) in zip(eks, data):
354
394
  values = {
355
395
  name: _proto_value_to_py(feature_map[name])
356
396
  for name in meta.field_names
357
397
  if name in feature_map
358
398
  }
359
399
  pc.validate_string_lengths(values, max_bytes)
360
- key = pc.data_key(prefix, sv, dv, self.serialize_entity_key(entity_key_proto))
400
+ key = pc.data_key(prefix, sv, dv, ek)
361
401
  ts_micros = None if event_ts is None else event_time_to_micros(event_ts)
362
402
  pipe.set(key, pc.encode_row(meta, ts_micros, values), ex=ttl)
363
403
  pipe.execute()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wedata3-feature-engineering
3
- Version: 0.2.6.dev2
3
+ Version: 0.2.6.dev3
4
4
  Summary: Wedata Feature Engineering Library Development
5
5
  Home-page:
6
6
  Author: meahqian
@@ -1,5 +1,6 @@
1
1
  README.md
2
2
  setup.py
3
+ tests/test_build_executor_deps.py
3
4
  tests/test_cleanup_packed.py
4
5
  tests/test_client.py
5
6
  tests/test_common_utils.py