perceptkit 0.2.2__py3-none-any.whl
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.
- perceptkit/__init__.py +85 -0
- perceptkit/algorithms/__init__.py +40 -0
- perceptkit/algorithms/attribution.py +147 -0
- perceptkit/algorithms/glance.py +236 -0
- perceptkit/algorithms/history.py +663 -0
- perceptkit/algorithms/identity.py +43 -0
- perceptkit/algorithms/observation.py +44 -0
- perceptkit/algorithms/streaks.py +111 -0
- perceptkit/algorithms/trend_models.py +184 -0
- perceptkit/algorithms/wake.py +149 -0
- perceptkit/catalog.py +252 -0
- perceptkit/conformance/__init__.py +28 -0
- perceptkit/conformance/memory.py +364 -0
- perceptkit/conformance/report.py +170 -0
- perceptkit/conformance/suite.py +419 -0
- perceptkit/conformance/wake.py +151 -0
- perceptkit/contracts/__init__.py +97 -0
- perceptkit/contracts/_time.py +89 -0
- perceptkit/contracts/availability.py +77 -0
- perceptkit/contracts/context.py +50 -0
- perceptkit/contracts/delivery.py +167 -0
- perceptkit/contracts/errors.py +22 -0
- perceptkit/contracts/event.py +137 -0
- perceptkit/contracts/observation.py +172 -0
- perceptkit/contracts/receipt.py +129 -0
- perceptkit/contracts/records.py +367 -0
- perceptkit/contracts/report.py +127 -0
- perceptkit/contracts/versioning.py +63 -0
- perceptkit/fields.py +184 -0
- perceptkit/kit.py +223 -0
- perceptkit/manifest/__init__.py +57 -0
- perceptkit/manifest/checks.py +323 -0
- perceptkit/manifest/mapping.py +96 -0
- perceptkit/manifest/minimal.py +1282 -0
- perceptkit/manifest/types.py +211 -0
- perceptkit/manifest/units.py +84 -0
- perceptkit/ports/__init__.py +19 -0
- perceptkit/ports/storage.py +288 -0
- perceptkit/ports/wake.py +43 -0
- perceptkit/processing/__init__.py +49 -0
- perceptkit/processing/aggregate.py +80 -0
- perceptkit/processing/dispatch.py +356 -0
- perceptkit/processing/normalize.py +458 -0
- perceptkit/processing/pipeline.py +406 -0
- perceptkit/processing/recompute.py +170 -0
- perceptkit/processing/recurrence.py +166 -0
- perceptkit/processing/scheduled.py +233 -0
- perceptkit/prompts.py +75 -0
- perceptkit/queries/__init__.py +32 -0
- perceptkit/queries/api.py +457 -0
- perceptkit/retention.py +84 -0
- perceptkit/rules/__init__.py +19 -0
- perceptkit/rules/engine.py +112 -0
- perceptkit/rules/evaluators.py +228 -0
- perceptkit/rules/types.py +236 -0
- perceptkit-0.2.2.dist-info/METADATA +439 -0
- perceptkit-0.2.2.dist-info/RECORD +59 -0
- perceptkit-0.2.2.dist-info/WHEEL +4 -0
- perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""处理管线 —— 谁在什么时候被调用。
|
|
2
|
+
|
|
3
|
+
**这是上一版真正缺的东西。** 算式都在,但顺序留在了宿主的业务代码里 ——
|
|
4
|
+
别人拿到的是一盒零件和一本没有装配图的说明书。
|
|
5
|
+
|
|
6
|
+
normalize 校验 · 定时区 · 算归属日期 · 算去重身份
|
|
7
|
+
aggregate 按 manifest 声明的策略路由到 history 里那批算法(不重新实现)
|
|
8
|
+
pipeline 前七步:批级幂等 -> 标准化 -> 观测级幂等 -> 写观测 ->
|
|
9
|
+
记身份 -> 更新当前值 -> 折进聚合
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .aggregate import aggregating_fields, fold_into_day
|
|
14
|
+
from .normalize import (
|
|
15
|
+
NormalizedObservation,
|
|
16
|
+
NormalizeResult,
|
|
17
|
+
normalize_observations,
|
|
18
|
+
validate_value,
|
|
19
|
+
)
|
|
20
|
+
from .pipeline import AGGREGATION_VERSION, IngestOutcome, ingest_report
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"normalize_observations", "validate_value",
|
|
24
|
+
"NormalizedObservation", "NormalizeResult",
|
|
25
|
+
"aggregating_fields", "fold_into_day",
|
|
26
|
+
"ingest_report", "IngestOutcome", "AGGREGATION_VERSION",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
from .dispatch import ( # noqa: E402
|
|
30
|
+
DispatchOutcome,
|
|
31
|
+
RuleOutcome,
|
|
32
|
+
dispatch_once,
|
|
33
|
+
drain,
|
|
34
|
+
evaluate_and_enqueue,
|
|
35
|
+
event_id_for,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .scheduled import ( # noqa: E402
|
|
39
|
+
ScheduledOutcome,
|
|
40
|
+
evaluate_absence,
|
|
41
|
+
evaluate_daily,
|
|
42
|
+
streak_length,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ += [
|
|
46
|
+
"evaluate_and_enqueue", "RuleOutcome", "event_id_for",
|
|
47
|
+
"dispatch_once", "drain", "DispatchOutcome",
|
|
48
|
+
"evaluate_daily", "evaluate_absence", "streak_length", "ScheduledOutcome",
|
|
49
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""聚合分派 —— manifest 说用哪种算法,这里去调。
|
|
2
|
+
|
|
3
|
+
**算法只有一份。** 这里不重新实现任何聚合,只是把 manifest 按【字段】声明的
|
|
4
|
+
``aggregation_strategy`` 路由到 ``history`` 里那批已经测过的 merger。
|
|
5
|
+
旧的按信号查表那条路(``history.record_daily``)原样保留,两条路共用同一批算法,
|
|
6
|
+
不会漂移。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from ..algorithms import history
|
|
13
|
+
from ..manifest.types import SignalDefinition
|
|
14
|
+
|
|
15
|
+
#: manifest 的策略名 -> history 的 shape 名。
|
|
16
|
+
#: ``daily_total`` 走 CUMULATIVE(日内单调累加,当天代表值取 max=总数)。
|
|
17
|
+
_STRATEGY_TO_SHAPE: dict[str, str] = {
|
|
18
|
+
"daily_total": history.CUMULATIVE,
|
|
19
|
+
"cumulative": history.CUMULATIVE,
|
|
20
|
+
"numeric_dist": history.NUMERIC_DIST,
|
|
21
|
+
"main_of_day": history.MAIN_OF_DAY,
|
|
22
|
+
"duration_by_state": history.DURATION_BY_STATE,
|
|
23
|
+
"event_list": history.EVENT_LIST,
|
|
24
|
+
"tally": history.TALLY,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def aggregating_fields(sig: SignalDefinition) -> list[tuple[str, str]]:
|
|
29
|
+
"""这个信号有哪些字段要聚合,各用哪种 shape。返回 ``[(字段名, shape), ...]``。"""
|
|
30
|
+
out: list[tuple[str, str]] = []
|
|
31
|
+
for f in sig.fields:
|
|
32
|
+
shape = _STRATEGY_TO_SHAPE.get(f.aggregation_strategy)
|
|
33
|
+
if shape is not None:
|
|
34
|
+
out.append((f.key, shape))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fold_into_day(
|
|
39
|
+
prev_doc: Mapping[str, Any] | None,
|
|
40
|
+
sig: SignalDefinition,
|
|
41
|
+
values: Mapping[str, Any],
|
|
42
|
+
*,
|
|
43
|
+
ts: float | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""把一条观测折进它那天的聚合文档。
|
|
46
|
+
|
|
47
|
+
一个信号可以有多个字段各自聚合(比如同时要"每日总数"和"按状态分时长"),
|
|
48
|
+
所以按字段逐个折,而不是整条 payload 一次性丢给某一个 merger。
|
|
49
|
+
|
|
50
|
+
``ts`` 是发生时刻的 epoch 秒。``duration_by_state`` 这类算法靠相邻两条
|
|
51
|
+
观测的时间差累计时长 —— **没有 ts 就只能记状态、算不出时长**。
|
|
52
|
+
"""
|
|
53
|
+
doc = dict(prev_doc or {})
|
|
54
|
+
for field_key, shape in aggregating_fields(sig):
|
|
55
|
+
if field_key not in values:
|
|
56
|
+
continue
|
|
57
|
+
doc = history.apply_shape(
|
|
58
|
+
# **只喂这一个字段。** merger 会把收到的 mapping 里的每个字段都
|
|
59
|
+
# 按自己那套算法写一遍 —— 整条 payload 递进去,一个字段声明的
|
|
60
|
+
# 算法就会写到所有字段头上。两个后果都真发生过:
|
|
61
|
+
#
|
|
62
|
+
# 声明 none 的字段被凭空聚合 weather 只有 temperature_c 声明了
|
|
63
|
+
# numeric_dist,结果 uv_index、湿度、
|
|
64
|
+
# 体感温度全被写了 min/max/sum/count。
|
|
65
|
+
# 同信号两种算法互相覆盖 health_vitals 同时有 numeric_dist
|
|
66
|
+
# (静息心率) 和 main_of_day (vo2_max):
|
|
67
|
+
# 后者把字段写成裸数字,当天第二条上报
|
|
68
|
+
# 进来时前者读 cell.get("min") 直接崩,
|
|
69
|
+
# **每个用户每天第二次上报都会炸**。
|
|
70
|
+
shape, doc, {field_key: values[field_key]},
|
|
71
|
+
signal=sig.key,
|
|
72
|
+
# duration_by_state 需要知道哪个字段是状态标签。manifest 按字段
|
|
73
|
+
# 声明,所以这里能直接给出来,不用像旧路径那样按信号查表。
|
|
74
|
+
state_field=field_key if shape == history.DURATION_BY_STATE else None,
|
|
75
|
+
ts=ts,
|
|
76
|
+
)
|
|
77
|
+
return doc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["aggregating_fields", "fold_into_day", "_STRATEGY_TO_SHAPE"]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""后六步 —— 求值、落地、投递、回执。
|
|
2
|
+
|
|
3
|
+
⑧ 读定义和规则状态,求值
|
|
4
|
+
⑨ 命中时**原子地**写规则状态 + 写待发件箱
|
|
5
|
+
⑩ 提交事务 ← ``ingest()`` 到这里就返回了
|
|
6
|
+
⑪ 调 WakePort ← 以下由宿主的 worker 驱动
|
|
7
|
+
⑫ 存回执
|
|
8
|
+
⑬ accepted 之后才把冷却额度占位兑现成真正的消耗
|
|
9
|
+
|
|
10
|
+
**第 ⑩ 步是分界线。** 上报接口同步做到"事件已落地并提交"就返回:走到这里
|
|
11
|
+
事件就丢不了了,后台慢慢投、崩了能重投,而手机不用等 runtime 的响应
|
|
12
|
+
—— runtime 一慢,上报接口就超时,客户端重传,雪上加霜。
|
|
13
|
+
|
|
14
|
+
**事件 id 是算出来的,不是随机的。** 由 ``(subject, 定义, 范围, 触发依据)``
|
|
15
|
+
确定性地导出 —— 同一次触发无论重算多少遍都是同一个 id,runtime 靠它幂等。
|
|
16
|
+
用随机 id 的话,一次重放就是一个新事件,用户被提醒两次。
|
|
17
|
+
(另外:这个包不读时钟也不生成随机数,那会让重放和测试都做不了。)
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from ..contracts import delivery as _delivery
|
|
26
|
+
from ..contracts.context import IngestContext
|
|
27
|
+
from ..contracts.event import EventCondition, PerceptionEvent, safe_context
|
|
28
|
+
from ..contracts.records import EventOutboxEntry
|
|
29
|
+
from ..contracts.receipt import WakeReceipt
|
|
30
|
+
from ..ports.storage import StoragePort
|
|
31
|
+
from ..ports.wake import WakePort
|
|
32
|
+
from ..rules.engine import evaluate, scope_key
|
|
33
|
+
from ..rules.types import EventDefinition, RuleResult, RuleState
|
|
34
|
+
from .normalize import NormalizedObservation, _digest
|
|
35
|
+
|
|
36
|
+
#: 一次投递最多重试几次。用尽进死信 —— 无限重试会让一个投不出去的事件
|
|
37
|
+
#: 永远占着 worker。
|
|
38
|
+
MAX_ATTEMPTS = 5
|
|
39
|
+
|
|
40
|
+
#: 重试的退避基数(秒)。第 n 次重试等 ``BACKOFF_BASE * 2**(n-1)`` 秒。
|
|
41
|
+
BACKOFF_BASE = 30.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def event_id_for(
|
|
45
|
+
*, subject_id: str, definition: EventDefinition, scope: str, trigger: str,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""确定性的事件 id。见模块开头 —— 随机 id 会让重放变成新事件。"""
|
|
48
|
+
return "evt_" + _digest(
|
|
49
|
+
subject_id, definition.definition_id, str(definition.version), scope, trigger,
|
|
50
|
+
)[:32]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RuleOutcome:
|
|
55
|
+
"""一条观测跑完所有相关规则的结果。"""
|
|
56
|
+
|
|
57
|
+
events: list[PerceptionEvent] = field(default_factory=list)
|
|
58
|
+
#: 求值了但没触发的,带原因。排查"为什么没提醒我"时要用。
|
|
59
|
+
misses: list[tuple[str, str | None]] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def definitions_for_signal(
|
|
63
|
+
definitions: Sequence[EventDefinition], *, signal: str, subject_id: str,
|
|
64
|
+
) -> list[EventDefinition]:
|
|
65
|
+
"""挑出和这个信号、这个用户相关的规则。
|
|
66
|
+
|
|
67
|
+
**按信号索引,不是每次全量遍历。** 前台每 30 秒一次上报,用户配了几十条
|
|
68
|
+
规则的话,全量求值会稳定占住 CPU。
|
|
69
|
+
"""
|
|
70
|
+
return [
|
|
71
|
+
d for d in definitions
|
|
72
|
+
if d.enabled and d.signal == signal
|
|
73
|
+
and (d.subject_id is None or d.subject_id == subject_id)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unit_of(sig: Any, field_name: str | None) -> str | None:
|
|
79
|
+
"""触发字段声明的单位。无量纲的(布尔、枚举)返回 ``None``。"""
|
|
80
|
+
if sig is None or not field_name:
|
|
81
|
+
return None
|
|
82
|
+
fd = sig.field_map().get(field_name)
|
|
83
|
+
return fd.unit if fd is not None else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def evaluate_and_enqueue(
|
|
87
|
+
item: NormalizedObservation,
|
|
88
|
+
*,
|
|
89
|
+
context: IngestContext,
|
|
90
|
+
storage: StoragePort,
|
|
91
|
+
definitions: Sequence[EventDefinition],
|
|
92
|
+
extra_evaluators: Mapping[str, Callable[..., RuleResult]] | None = None,
|
|
93
|
+
extra_context: Mapping[str, Any] | None = None,
|
|
94
|
+
signal_definition: Any = None,
|
|
95
|
+
) -> RuleOutcome:
|
|
96
|
+
"""⑧⑨:对一条已经落地的观测求值,命中就写发件箱。
|
|
97
|
+
|
|
98
|
+
**规则状态和发件箱必须同事务。** 分开写的话会出现"状态说已经触发过了、
|
|
99
|
+
但事件没进发件箱" —— 这次触发就永远丢了,而且规则要等到下一个范围
|
|
100
|
+
才会重新武装。
|
|
101
|
+
"""
|
|
102
|
+
outcome = RuleOutcome()
|
|
103
|
+
stored = item.stored
|
|
104
|
+
relevant = definitions_for_signal(
|
|
105
|
+
definitions, signal=stored.signal, subject_id=context.subject_id
|
|
106
|
+
)
|
|
107
|
+
if not relevant:
|
|
108
|
+
return outcome
|
|
109
|
+
|
|
110
|
+
values = stored.typed_value or {}
|
|
111
|
+
for definition in relevant:
|
|
112
|
+
# 状态键带上定义版本:当天把阈值规则从 v1 改成 v2,v1 的
|
|
113
|
+
# "今天已经触发过"不该继续压制 v2 —— 用户改了规则却不生效,
|
|
114
|
+
# 而且没有任何地方报错。
|
|
115
|
+
scope = "%s@v%d" % (
|
|
116
|
+
scope_key(definition, local_date=stored.effective_local_date),
|
|
117
|
+
definition.version,
|
|
118
|
+
)
|
|
119
|
+
raw_state = storage.get_rule_state(
|
|
120
|
+
subject_id=context.subject_id,
|
|
121
|
+
definition_id=definition.definition_id,
|
|
122
|
+
scope_key=scope,
|
|
123
|
+
)
|
|
124
|
+
state = RuleState.from_dict(raw_state)
|
|
125
|
+
|
|
126
|
+
current = values.get(definition.field_name) if definition.field_name else None
|
|
127
|
+
ctx: dict[str, Any] = {
|
|
128
|
+
"source_event_id": stored.source_event_id or item.identity_digest,
|
|
129
|
+
"signal": stored.signal,
|
|
130
|
+
"occurred_at": stored.occurred_at,
|
|
131
|
+
}
|
|
132
|
+
ctx.update(extra_context or {})
|
|
133
|
+
|
|
134
|
+
result = evaluate(
|
|
135
|
+
definition, state, current,
|
|
136
|
+
now=stored.received_at,
|
|
137
|
+
context=ctx,
|
|
138
|
+
extra_evaluators=extra_evaluators,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 状态**每次都要写回**,哪怕没触发 —— 不推进 previous_value 的话,
|
|
142
|
+
# threshold_crossing 永远拿不到正确的前值,规则就成了死的。
|
|
143
|
+
storage.put_rule_state(
|
|
144
|
+
subject_id=context.subject_id,
|
|
145
|
+
definition_id=definition.definition_id,
|
|
146
|
+
scope_key=scope,
|
|
147
|
+
state=result.state.to_dict(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if not result.fired:
|
|
151
|
+
outcome.misses.append((definition.definition_id, result.reason))
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
trigger = ctx["source_event_id"] if definition.condition_type == "occurrence" \
|
|
155
|
+
else f"{result.previous!r}->{result.current!r}"
|
|
156
|
+
event = PerceptionEvent(
|
|
157
|
+
event_id=event_id_for(
|
|
158
|
+
subject_id=context.subject_id, definition=definition,
|
|
159
|
+
scope=scope, trigger=str(trigger),
|
|
160
|
+
),
|
|
161
|
+
definition_id=definition.definition_id,
|
|
162
|
+
definition_version=definition.version,
|
|
163
|
+
subject_id=context.subject_id,
|
|
164
|
+
type=definition.event_type,
|
|
165
|
+
signal=stored.signal,
|
|
166
|
+
occurred_at=stored.occurred_at,
|
|
167
|
+
received_at=stored.received_at,
|
|
168
|
+
condition=EventCondition(
|
|
169
|
+
type=definition.condition_type,
|
|
170
|
+
operator=definition.operator,
|
|
171
|
+
value=definition.value,
|
|
172
|
+
),
|
|
173
|
+
field_name=definition.field_name,
|
|
174
|
+
previous=result.previous,
|
|
175
|
+
current=result.current,
|
|
176
|
+
# 受控的附加事实。**不透传整个存储 doc** —— 那既撑爆上下文也漏隐私。
|
|
177
|
+
# reason 来自 evaluator,而宿主可以注册自己的 evaluator,所以这里
|
|
178
|
+
# 不能直接信它 —— 一律过 safe_context。
|
|
179
|
+
context=safe_context({
|
|
180
|
+
"scope": scope,
|
|
181
|
+
"reason": result.reason,
|
|
182
|
+
# 带上单位。数字离开 manifest 之后就没别的地方能说清
|
|
183
|
+
# 它是步数、毫升还是分钟了。
|
|
184
|
+
"unit": _unit_of(signal_definition, definition.field_name),
|
|
185
|
+
}),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# 🔴 事件【一律落地】。wake_enabled 只决定进不进可投递状态,
|
|
189
|
+
# 不决定这个事实存不存 —— 之前不唤醒的事件只放进内存返回值,
|
|
190
|
+
# 进程一崩就永久丢失,而规则状态已经推进、不会再产生它。
|
|
191
|
+
entry = EventOutboxEntry(
|
|
192
|
+
event_id=event.event_id,
|
|
193
|
+
subject_id=context.subject_id,
|
|
194
|
+
definition_id=definition.definition_id,
|
|
195
|
+
definition_version=definition.version,
|
|
196
|
+
event_type=definition.event_type,
|
|
197
|
+
occurred_at=stored.occurred_at,
|
|
198
|
+
detected_at=stored.received_at,
|
|
199
|
+
fact_snapshot=event.to_dict(),
|
|
200
|
+
dedupe_key=event.event_id,
|
|
201
|
+
created_at=stored.received_at,
|
|
202
|
+
delivery_state=(_delivery.PENDING if definition.wake_enabled
|
|
203
|
+
else _delivery.NOT_DISPATCHED),
|
|
204
|
+
)
|
|
205
|
+
if storage.enqueue_event(entry):
|
|
206
|
+
outcome.events.append(event)
|
|
207
|
+
else:
|
|
208
|
+
# 同一个 event_id 已经在发件箱里 —— 这不是错误,是幂等生效了。
|
|
209
|
+
outcome.misses.append((definition.definition_id, "事件已在发件箱中"))
|
|
210
|
+
|
|
211
|
+
return outcome
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# 投递(由宿主的 worker 驱动)
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class DispatchOutcome:
|
|
220
|
+
delivered: list[str] = field(default_factory=list)
|
|
221
|
+
retrying: list[str] = field(default_factory=list)
|
|
222
|
+
dead: list[str] = field(default_factory=list)
|
|
223
|
+
suppressed: list[str] = field(default_factory=list)
|
|
224
|
+
rejected: list[str] = field(default_factory=list)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _backoff(attempt: int) -> timedelta:
|
|
228
|
+
return timedelta(seconds=BACKOFF_BASE * (2 ** max(0, attempt - 1)))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
#: 快照里没有 condition 时用的占位。**不是空字符串** —— 空的 type 会一路传到
|
|
232
|
+
#: 宿主那里,看起来像「这条规则没有条件」,而实情是「我们弄丢了它」。
|
|
233
|
+
UNKNOWN_CONDITION_TYPE = "unknown"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _condition_from(raw: Any) -> EventCondition:
|
|
237
|
+
"""从发件箱快照重建判据。**残缺就用占位,不抛异常。**"""
|
|
238
|
+
fields = {k: v for k, v in (raw or {}).items()
|
|
239
|
+
if k in ("type", "operator", "value")}
|
|
240
|
+
if not fields.get("type"):
|
|
241
|
+
fields["type"] = UNKNOWN_CONDITION_TYPE
|
|
242
|
+
return EventCondition(**fields)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def dispatch_once(
|
|
246
|
+
*,
|
|
247
|
+
storage: StoragePort,
|
|
248
|
+
wake: WakePort,
|
|
249
|
+
worker_id: str,
|
|
250
|
+
now: datetime,
|
|
251
|
+
lease_seconds: float = 60.0,
|
|
252
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
253
|
+
) -> DispatchOutcome | None:
|
|
254
|
+
"""领一个待投递事件、投出去、存回执。没有可领的返回 ``None``。
|
|
255
|
+
|
|
256
|
+
``WakePort.wake`` 抛异常时按 ``enqueue_failed`` 处理 —— **不能当成功**。
|
|
257
|
+
"结果未知"和"失败"要走同一条路(重试 + 靠 ``event_id`` 幂等兜底),
|
|
258
|
+
因为盲目当成功会让事件永远送不到。
|
|
259
|
+
"""
|
|
260
|
+
entry = storage.claim_pending_event(
|
|
261
|
+
worker_id=worker_id, now=now, lease_seconds=lease_seconds
|
|
262
|
+
)
|
|
263
|
+
if entry is None:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
outcome = DispatchOutcome()
|
|
267
|
+
event = PerceptionEvent(
|
|
268
|
+
event_id=entry.event_id,
|
|
269
|
+
definition_id=entry.definition_id,
|
|
270
|
+
definition_version=entry.definition_version,
|
|
271
|
+
subject_id=entry.subject_id,
|
|
272
|
+
type=entry.event_type,
|
|
273
|
+
signal=entry.fact_snapshot.get("signal", ""),
|
|
274
|
+
occurred_at=entry.occurred_at,
|
|
275
|
+
received_at=entry.detected_at,
|
|
276
|
+
# 快照里没有 condition 的话,用一个明确的占位而不是让构造炸掉。
|
|
277
|
+
# 发件箱里一条残缺记录(老版本写的、写了一半的)**不能停掉所有人的
|
|
278
|
+
# 投递** —— 而它会:drain 是一个循环,一条抛异常整轮就结束,
|
|
279
|
+
# 后面排队的事件谁也送不出去,还没有任何地方说为什么。
|
|
280
|
+
condition=_condition_from(entry.fact_snapshot.get("condition")),
|
|
281
|
+
field_name=entry.fact_snapshot.get("field"),
|
|
282
|
+
previous=entry.fact_snapshot.get("previous"),
|
|
283
|
+
current=entry.fact_snapshot.get("current"),
|
|
284
|
+
context=safe_context(entry.fact_snapshot.get("context")),
|
|
285
|
+
)
|
|
286
|
+
attempt = _delivery.DeliveryAttempt(
|
|
287
|
+
event_id=entry.event_id,
|
|
288
|
+
attempt_id=f"{entry.event_id}:{entry.attempt_count}",
|
|
289
|
+
attempt_number=max(1, entry.attempt_count),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
receipt = wake.wake(event, attempt)
|
|
294
|
+
except Exception as exc: # noqa: BLE001 —— 见 docstring
|
|
295
|
+
receipt = WakeReceipt(
|
|
296
|
+
event_id=entry.event_id, attempt_id=attempt.attempt_id,
|
|
297
|
+
status="enqueue_failed", received_at=now,
|
|
298
|
+
reason=f"{type(exc).__name__}: {exc}",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
attempts_left = entry.attempt_count < max_attempts
|
|
302
|
+
next_state = _delivery.next_state_for_receipt(
|
|
303
|
+
receipt.status, attempts_left=attempts_left
|
|
304
|
+
)
|
|
305
|
+
next_at = (
|
|
306
|
+
now + _backoff(entry.attempt_count)
|
|
307
|
+
if next_state == _delivery.PENDING else None
|
|
308
|
+
)
|
|
309
|
+
accepted = storage.record_wake_receipt(
|
|
310
|
+
receipt=receipt, next_state=next_state,
|
|
311
|
+
claim_token=entry.claim_token, next_attempt_at=next_at,
|
|
312
|
+
)
|
|
313
|
+
if accepted is False:
|
|
314
|
+
# 令牌过期:这个事件在我们投递期间被别人接管了。回执只进审计,
|
|
315
|
+
# 状态归新 owner 管 —— 我们这一次的结果不算数。
|
|
316
|
+
outcome.retrying.append(entry.event_id)
|
|
317
|
+
return outcome
|
|
318
|
+
|
|
319
|
+
bucket = {
|
|
320
|
+
_delivery.DELIVERED: outcome.delivered,
|
|
321
|
+
_delivery.PENDING: outcome.retrying,
|
|
322
|
+
_delivery.DEAD_LETTER: outcome.dead,
|
|
323
|
+
_delivery.SUPPRESSED: outcome.suppressed,
|
|
324
|
+
_delivery.REJECTED: outcome.rejected,
|
|
325
|
+
}[next_state]
|
|
326
|
+
bucket.append(entry.event_id)
|
|
327
|
+
return outcome
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def drain(
|
|
331
|
+
*, storage: StoragePort, wake: WakePort, worker_id: str, now: datetime,
|
|
332
|
+
limit: int = 100, lease_seconds: float = 60.0,
|
|
333
|
+
) -> DispatchOutcome:
|
|
334
|
+
"""把当前能投的都投一遍。给宿主的 worker 循环用。
|
|
335
|
+
|
|
336
|
+
``limit`` 不是可选的:不设上限的话,积压很多时这一轮会跑很久,
|
|
337
|
+
而租约是有到期时间的 —— 跑太久会让前面已经领的事件被别人接管。
|
|
338
|
+
"""
|
|
339
|
+
total = DispatchOutcome()
|
|
340
|
+
for _ in range(limit):
|
|
341
|
+
one = dispatch_once(
|
|
342
|
+
storage=storage, wake=wake, worker_id=worker_id,
|
|
343
|
+
now=now, lease_seconds=lease_seconds,
|
|
344
|
+
)
|
|
345
|
+
if one is None:
|
|
346
|
+
break
|
|
347
|
+
for name in ("delivered", "retrying", "dead", "suppressed", "rejected"):
|
|
348
|
+
getattr(total, name).extend(getattr(one, name))
|
|
349
|
+
return total
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
__all__ = [
|
|
353
|
+
"MAX_ATTEMPTS", "BACKOFF_BASE", "UNKNOWN_CONDITION_TYPE", "event_id_for", "definitions_for_signal",
|
|
354
|
+
"evaluate_and_enqueue", "RuleOutcome", "DispatchOutcome",
|
|
355
|
+
"dispatch_once", "drain",
|
|
356
|
+
]
|