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.
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/PKG-INFO +1 -1
- wedata3_feature_engineering-0.2.6.dev3/tests/test_build_executor_deps.py +52 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_cleanup_packed.py +140 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_packed_codec.py +45 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_redis_packed.py +49 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_sync_template.py +47 -1
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/constants.py +1 -1
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/online_store/packed_codec.py +64 -6
- {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
- {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
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/PKG-INFO +1 -1
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/SOURCES.txt +1 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/README.md +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/setup.cfg +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/setup.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_common_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feast_client_validate_keys.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_spec_training_set.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_store.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_feature_table_client_validate_keys.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_table_client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/tests/test_table_client_dispatch.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/base_table_client/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/base_table_client/base.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/models.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/cloud_sdk_client/utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/constants/engine_types.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/column_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/environment_variables.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_column_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_function.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_lookup.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_spec.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_spec_constants.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_table.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/feature_table_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/function_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/on_demand_column_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/source_data_column_info.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/entities/training_set.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/feast_client/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/feast_client/feast_client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/log/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/log/logger.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/online_store/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/protos/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/protos/feature_store_pb2.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/spark_client/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/spark_client/spark_client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/common_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/env_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_lookup_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_spec_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/feature_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/on_demand_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/schema_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/signature_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/topological_sort.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/training_set_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/uc_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/common/utils/validation_utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/feast/infra/__init__.py +0 -0
- {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
- {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
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/ml_training_client/__init__.py +0 -0
- {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
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/mlflow_model.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/table_client/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_engineering/table_client/table_client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/client.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/store_config/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/common/store_config/redis.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/constants/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/constants/engine_types.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/feature_table_client/__init__.py +0 -0
- {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
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/mlflow_model.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/feature_store/training_set_client/__init__.py +0 -0
- {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
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/__init__.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/interpol.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/intervals.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/io.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/ml.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/resample.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/tsdf.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/utils.py +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/dependency_links.txt +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/requires.txt +0 -0
- {wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata3_feature_engineering.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
|
633
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
"""
|
|
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:
|
|
263
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/io.py
RENAMED
|
File without changes
|
{wedata3_feature_engineering-0.2.6.dev2 → wedata3_feature_engineering-0.2.6.dev3}/wedata/tempo/ml.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|