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.
Files changed (59) hide show
  1. perceptkit/__init__.py +85 -0
  2. perceptkit/algorithms/__init__.py +40 -0
  3. perceptkit/algorithms/attribution.py +147 -0
  4. perceptkit/algorithms/glance.py +236 -0
  5. perceptkit/algorithms/history.py +663 -0
  6. perceptkit/algorithms/identity.py +43 -0
  7. perceptkit/algorithms/observation.py +44 -0
  8. perceptkit/algorithms/streaks.py +111 -0
  9. perceptkit/algorithms/trend_models.py +184 -0
  10. perceptkit/algorithms/wake.py +149 -0
  11. perceptkit/catalog.py +252 -0
  12. perceptkit/conformance/__init__.py +28 -0
  13. perceptkit/conformance/memory.py +364 -0
  14. perceptkit/conformance/report.py +170 -0
  15. perceptkit/conformance/suite.py +419 -0
  16. perceptkit/conformance/wake.py +151 -0
  17. perceptkit/contracts/__init__.py +97 -0
  18. perceptkit/contracts/_time.py +89 -0
  19. perceptkit/contracts/availability.py +77 -0
  20. perceptkit/contracts/context.py +50 -0
  21. perceptkit/contracts/delivery.py +167 -0
  22. perceptkit/contracts/errors.py +22 -0
  23. perceptkit/contracts/event.py +137 -0
  24. perceptkit/contracts/observation.py +172 -0
  25. perceptkit/contracts/receipt.py +129 -0
  26. perceptkit/contracts/records.py +367 -0
  27. perceptkit/contracts/report.py +127 -0
  28. perceptkit/contracts/versioning.py +63 -0
  29. perceptkit/fields.py +184 -0
  30. perceptkit/kit.py +223 -0
  31. perceptkit/manifest/__init__.py +57 -0
  32. perceptkit/manifest/checks.py +323 -0
  33. perceptkit/manifest/mapping.py +96 -0
  34. perceptkit/manifest/minimal.py +1282 -0
  35. perceptkit/manifest/types.py +211 -0
  36. perceptkit/manifest/units.py +84 -0
  37. perceptkit/ports/__init__.py +19 -0
  38. perceptkit/ports/storage.py +288 -0
  39. perceptkit/ports/wake.py +43 -0
  40. perceptkit/processing/__init__.py +49 -0
  41. perceptkit/processing/aggregate.py +80 -0
  42. perceptkit/processing/dispatch.py +356 -0
  43. perceptkit/processing/normalize.py +458 -0
  44. perceptkit/processing/pipeline.py +406 -0
  45. perceptkit/processing/recompute.py +170 -0
  46. perceptkit/processing/recurrence.py +166 -0
  47. perceptkit/processing/scheduled.py +233 -0
  48. perceptkit/prompts.py +75 -0
  49. perceptkit/queries/__init__.py +32 -0
  50. perceptkit/queries/api.py +457 -0
  51. perceptkit/retention.py +84 -0
  52. perceptkit/rules/__init__.py +19 -0
  53. perceptkit/rules/engine.py +112 -0
  54. perceptkit/rules/evaluators.py +228 -0
  55. perceptkit/rules/types.py +236 -0
  56. perceptkit-0.2.2.dist-info/METADATA +439 -0
  57. perceptkit-0.2.2.dist-info/RECORD +59 -0
  58. perceptkit-0.2.2.dist-info/WHEEL +4 -0
  59. 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
+ ]