streamgate 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {streamgate-0.4.0 → streamgate-0.5.0}/CHANGELOG.md +24 -0
  2. {streamgate-0.4.0 → streamgate-0.5.0}/PKG-INFO +1 -1
  3. {streamgate-0.4.0 → streamgate-0.5.0}/README.zh-CN.md +5 -1
  4. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/__init__.py +1 -1
  5. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/admission.py +17 -3
  6. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/in_memory.py +17 -4
  7. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/no_admission.py +11 -1
  8. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/gateway.py +83 -23
  9. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/protocols.py +15 -3
  10. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/specs.py +2 -1
  11. {streamgate-0.4.0 → streamgate-0.5.0}/.github/workflows/ci.yml +0 -0
  12. {streamgate-0.4.0 → streamgate-0.5.0}/.github/workflows/release.yml +0 -0
  13. {streamgate-0.4.0 → streamgate-0.5.0}/.gitignore +0 -0
  14. {streamgate-0.4.0 → streamgate-0.5.0}/AGENTS.md +0 -0
  15. {streamgate-0.4.0 → streamgate-0.5.0}/CONFIGURATION.md +0 -0
  16. {streamgate-0.4.0 → streamgate-0.5.0}/CONTRIBUTING.md +0 -0
  17. {streamgate-0.4.0 → streamgate-0.5.0}/LICENSE +0 -0
  18. {streamgate-0.4.0 → streamgate-0.5.0}/README.md +0 -0
  19. {streamgate-0.4.0 → streamgate-0.5.0}/examples/docker-compose.yml +0 -0
  20. {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/README.md +0 -0
  21. {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/consume.py +0 -0
  22. {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/health_server.py +0 -0
  23. {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/models.py +0 -0
  24. {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/produce.py +0 -0
  25. {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/README.md +0 -0
  26. {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/consume.py +0 -0
  27. {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/health_server.py +0 -0
  28. {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/models.py +0 -0
  29. {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/produce.py +0 -0
  30. {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/README.md +0 -0
  31. {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/consume.py +0 -0
  32. {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/models.py +0 -0
  33. {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/produce.py +0 -0
  34. {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/README.md +0 -0
  35. {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/models.py +0 -0
  36. {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/produce.py +0 -0
  37. {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/README.md +0 -0
  38. {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/consume.py +0 -0
  39. {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/models.py +0 -0
  40. {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/produce.py +0 -0
  41. {streamgate-0.4.0 → streamgate-0.5.0}/pyproject.toml +0 -0
  42. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/config.py +0 -0
  43. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/__init__.py +0 -0
  44. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/classifier.py +0 -0
  45. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/dlq.py +0 -0
  46. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/loop.py +0 -0
  47. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/runner.py +0 -0
  48. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/__init__.py +0 -0
  49. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/_deps.py +0 -0
  50. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
  51. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
  52. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/mssql_sink/__init__.py +0 -0
  53. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/__init__.py +0 -0
  54. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/config.py +0 -0
  55. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/existence.py +0 -0
  56. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/__init__.py +0 -0
  57. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/__init__.py +0 -0
  58. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/mssql.py +0 -0
  59. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/sqlite.py +0 -0
  60. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/backfill.py +0 -0
  61. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/classifier.py +0 -0
  62. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/config.py +0 -0
  63. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/engines.py +0 -0
  64. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/upsert.py +0 -0
  65. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sqlite_sink/__init__.py +0 -0
  66. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/__init__.py +0 -0
  67. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/__init__.py +0 -0
  68. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/producer.py +0 -0
  69. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/__init__.py +0 -0
  70. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/logging.py +0 -0
  71. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/metrics.py +0 -0
  72. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/__init__.py +0 -0
  73. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/backpressure.py +0 -0
  74. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/health.py +0 -0
  75. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/__init__.py +0 -0
  76. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/codec.py +0 -0
  77. {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/kafka.py +0 -0
  78. {streamgate-0.4.0 → streamgate-0.5.0}/uv.lock +0 -0
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-09-10
9
+
10
+ ### Added
11
+
12
+ - **`AdmissionPolicy` gains `on_send_success` / `on_send_failed` hooks.**
13
+ `on_send_success(record)` fires after every broker-acked Kafka send
14
+ (overwrite path included); `on_send_failed(record)` fires when a send
15
+ fails after the admission reservation was placed. The framework default
16
+ (no-op) preserves current behavior — the placeholder stays reserved and
17
+ self-heals via TTL/backfill — but implementations can now release the
18
+ reservation to make the key immediately re-submittable, accepting the
19
+ duplicate risk of ambiguous (timeout) failures.
20
+
21
+ ### Changed
22
+
23
+ - **`AdmissionPolicy.on_accepted` renamed to `on_overwrite_accepted`.**
24
+ The old hook only fired on the overwrite path (not on every accepted
25
+ send, despite the name); the new name states that. Custom policies that
26
+ implement `on_accepted` keep working: the gateway falls back to it when
27
+ `on_overwrite_accepted` is absent. Built-in policies provide both
28
+ (the old name delegates to the new one).
29
+
8
30
  ## [0.4.0] - 2026-09-08
9
31
 
10
32
  ### Added
@@ -214,5 +236,7 @@ First public release. Data pipeline framework: conditional admission → reliabl
214
236
  - Mechanisms as core dependencies; all policy carriers (DB drivers, redis, httpx) as optional extras with lazy loading and install-guidance errors.
215
237
  - Typed configuration objects mapping 1:1 to `KAFKA__*` / `CONSUMER__*` / `DB__*` / `REDIS__*` / `BACKPRESSURE__*` environment variables; required settings fail fast at startup.
216
238
 
239
+ [0.5.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.5.0
240
+ [0.4.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.4.0
217
241
  [0.2.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.2.0
218
242
  [0.1.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: streamgate
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A pure-core Kafka data pipeline framework: conditional admission, reliable delivery, pluggable sinks via typed hooks.
5
5
  Project-URL: Homepage, https://github.com/pwg-code/streamgate
6
6
  Project-URL: Repository, https://github.com/pwg-code/streamgate
@@ -173,7 +173,11 @@ worker = ConsumerWorker(spec, ..., error_classifier=MyClassifier())
173
173
  ```python
174
174
  class MyAdmission:
175
175
  async def admit(self, record, *, overwrite=False): ... # 判定:允许/冲突/拒绝
176
- async def on_accepted(self, record): ... # Kafka 发送成功后
176
+ async def on_send_success(self, record): ... # 每次 Kafka 发送成功后(通知型)
177
+ async def on_send_failed(self, record): ... # 发送失败后;默认保留占位自愈,
178
+ # 也可在此释放占位换"立即可重发"
179
+ async def on_overwrite_accepted(self, record): ... # 仅 overwrite:摘要写,返回 False
180
+ # → cache_updated=false
177
181
  async def on_persisted(self, record): ... # 消费落库成功后刷新
178
182
  # 另有 start / close / 健康探测方法,见 protocols.py
179
183
 
@@ -79,7 +79,7 @@ from streamgate.specs import ConsumeSpec, IngestBinding, IngestRecordT
79
79
  from streamgate.transport.codec import JsonEnvelopeCodec
80
80
  from streamgate.transport.kafka import KafkaConsumerService
81
81
 
82
- __version__ = "0.4.0"
82
+ __version__ = "0.5.0"
83
83
 
84
84
  __all__ = [
85
85
  "AdmissionPolicy",
@@ -3,8 +3,9 @@
3
3
  协议适配层:entity+slot 原子占位(Lua)、idle-GC TTL、
4
4
  空实体哨兵、fail-closed、overwrite 预检;TTL/前缀/降级开关/错误码全参数化。
5
5
 
6
- 框架保证调用时序:admit → (Kafka 发送) → on_accepted;消费侧
7
- write 成功 → on_persisted(准入与消费侧的唯一耦合点)。
6
+ 框架保证调用时序:admit → (Kafka 发送) → on_send_success(每次成功,通知型)/
7
+ on_send_failed(每次失败,默认保留占位 TTL 自愈)→ on_overwrite_accepted(仅
8
+ overwrite 路径);消费侧 write 成功 → on_persisted(准入与消费侧的唯一耦合点)。
8
9
  """
9
10
 
10
11
  import asyncio
@@ -197,7 +198,16 @@ class RedisExistenceAdmission(Generic[RecordT]):
197
198
  return Decision.allow()
198
199
  return result
199
200
 
200
- async def on_accepted(self, record: RecordT) -> bool:
201
+ async def on_send_success(self, record: RecordT) -> None:
202
+ """通知型钩子:占位已在 admit 原子写入,无需动作。"""
203
+ return None
204
+
205
+ async def on_send_failed(self, record: RecordT) -> None:
206
+ """默认保留占位(TTL 过后冷路径回源自愈);需"失败立即可重发"可在此
207
+ 删除 existence key 的 field(自担模糊失败重复风险)。"""
208
+ return None
209
+
210
+ async def on_overwrite_accepted(self, record: RecordT) -> bool:
201
211
  """overwrite 摘要写:失败重试 1 次;仍失败 ERROR(供告警删 key)并返回 False。"""
202
212
  entity = str(self._entity_key(record))
203
213
  slot = str(self._slot_key(record))
@@ -215,6 +225,10 @@ class RedisExistenceAdmission(Generic[RecordT]):
215
225
  )
216
226
  return False
217
227
 
228
+ async def on_accepted(self, record: RecordT) -> bool:
229
+ """向后兼容别名:等价 on_overwrite_accepted。"""
230
+ return await self.on_overwrite_accepted(record)
231
+
218
232
  async def on_persisted(self, record: RecordT) -> None:
219
233
  """落库成功 → 提交 offset 前的权威缓存刷新(失败 WARN 不影响提交)。"""
220
234
  entity = str(self._entity_key(record))
@@ -18,8 +18,9 @@ from streamgate.protocols import Decision, JsonObject, RecordT
18
18
  class InMemoryAdmission(Generic[RecordT]):
19
19
  """唯一性契约的进程内实现:检查 + 原子占位 + 摘要刷新。
20
20
 
21
- 框架保证调用时序:admit → (Kafka 发送) → on_accepted(仅 overwrite 路径);
22
- 消费侧落库成功 → on_persisted(权威刷新)。
21
+ 框架保证调用时序:admit → (Kafka 发送) → on_send_success(每次成功,通知型)/
22
+ on_send_failed(每次失败,默认保留占位自愈)→ on_overwrite_accepted(仅
23
+ overwrite 路径);消费侧落库成功 → on_persisted(权威刷新)。
23
24
  """
24
25
 
25
26
  def __init__(
@@ -37,7 +38,7 @@ class InMemoryAdmission(Generic[RecordT]):
37
38
 
38
39
  async def admit(self, record: RecordT, *, overwrite: bool = False) -> Decision:
39
40
  if overwrite:
40
- # 409 确认后的完整重发:跳过唯一性判定(摘要由 on_accepted 刷新)
41
+ # 409 确认后的完整重发:跳过唯一性判定(摘要由 on_overwrite_accepted 刷新)
41
42
  return Decision.allow()
42
43
  key = (str(self._entity_key(record)), str(self._slot_key(record)))
43
44
  existing = self._store.get(key)
@@ -46,13 +47,25 @@ class InMemoryAdmission(Generic[RecordT]):
46
47
  self._store[key] = self._summary(record)
47
48
  return Decision.allow()
48
49
 
49
- async def on_accepted(self, record: RecordT) -> bool:
50
+ async def on_send_success(self, record: RecordT) -> None:
51
+ """通知型钩子:占位已在 admit 写入,无需动作。"""
52
+ return None
53
+
54
+ async def on_send_failed(self, record: RecordT) -> None:
55
+ """默认保留占位(自愈);需"失败立即可重发"可在此删除 _store 键。"""
56
+ return None
57
+
58
+ async def on_overwrite_accepted(self, record: RecordT) -> bool:
50
59
  """overwrite 路径摘要写(内存操作恒成功)。"""
51
60
  self._store[(str(self._entity_key(record)), str(self._slot_key(record)))] = (
52
61
  self._summary(record)
53
62
  )
54
63
  return True
55
64
 
65
+ async def on_accepted(self, record: RecordT) -> bool:
66
+ """向后兼容别名:等价 on_overwrite_accepted。"""
67
+ return await self.on_overwrite_accepted(record)
68
+
56
69
  async def on_persisted(self, record: RecordT) -> None:
57
70
  """落库成功后的权威摘要刷新(幂等)。"""
58
71
  self._store[(str(self._entity_key(record)), str(self._slot_key(record)))] = (
@@ -11,9 +11,19 @@ class NoAdmission(Generic[RecordT]):
11
11
  async def admit(self, record: RecordT, *, overwrite: bool = False) -> Decision:
12
12
  return Decision.allow()
13
13
 
14
- async def on_accepted(self, record: RecordT) -> bool:
14
+ async def on_send_success(self, record: RecordT) -> None:
15
+ return None
16
+
17
+ async def on_send_failed(self, record: RecordT) -> None:
18
+ return None
19
+
20
+ async def on_overwrite_accepted(self, record: RecordT) -> bool:
15
21
  return True
16
22
 
23
+ async def on_accepted(self, record: RecordT) -> bool:
24
+ """向后兼容别名:等价 on_overwrite_accepted。"""
25
+ return await self.on_overwrite_accepted(record)
26
+
17
27
  async def on_persisted(self, record: RecordT) -> None:
18
28
  return None
19
29
 
@@ -1,4 +1,4 @@
1
- """IngestGateway:传输无关的接收编排内核(背压 → 准入 → 发送 → on_accepted)。
1
+ """IngestGateway:传输无关的接收编排内核(背压 → 准入 → 发送 → 发送结果钩子)。
2
2
 
3
3
  HTTP 鉴权/路由/OpenAPI 等呈现职责归使用方适配层:process() 返回传输无关的
4
4
  IngestOutcome,不抛 HTTP 异常、不构造 Response。日志事件名与字段为稳定观测
@@ -6,8 +6,9 @@ IngestOutcome,不抛 HTTP 异常、不构造 Response。日志事件名与字
6
6
  """
7
7
 
8
8
  import time
9
+ from collections.abc import Awaitable, Callable
9
10
  from datetime import datetime, timezone
10
- from typing import Generic
11
+ from typing import Generic, cast
11
12
 
12
13
  from streamgate.config import BackpressureConfig, KafkaConfig, MetricsConfig
13
14
  from streamgate.ingest.admission.in_memory import InMemoryAdmission
@@ -37,6 +38,16 @@ def _iso_z(dt: datetime) -> str:
37
38
  return dt.isoformat().replace("+00:00", "Z")
38
39
 
39
40
 
41
+ def _optional_hook(
42
+ admission: AdmissionPolicy[IngestRecordT], name: str
43
+ ) -> Callable[[IngestRecordT], Awaitable[object]] | None:
44
+ """按名解析可选钩子:旧版策略缺新钩子时返回 None(向后兼容)。"""
45
+ candidate: object | None = getattr(admission, name, None)
46
+ if not callable(candidate):
47
+ return None
48
+ return cast("Callable[[IngestRecordT], Awaitable[object]]", candidate)
49
+
50
+
40
51
  class IngestGateway(Generic[IngestRecordT]):
41
52
  """接收机制内核:start/close 管资源生命周期,process 跑单条接收链路。"""
42
53
 
@@ -141,7 +152,7 @@ class IngestGateway(Generic[IngestRecordT]):
141
152
  overwrite: bool | None = None,
142
153
  source: str = "unknown",
143
154
  ) -> IngestOutcome:
144
- """接收单条记录:背压 → 准入 → Kafka 发送 → on_accepted。
155
+ """接收单条记录:背压 → 准入 → Kafka 发送 → 发送结果钩子。
145
156
 
146
157
  overwrite=None 时按 binding.is_overwrite(record) 解析。
147
158
  未 start() 直接抛 RuntimeError(装配错误,非运行时降级)。
@@ -247,9 +258,9 @@ class IngestGateway(Generic[IngestRecordT]):
247
258
  source: str,
248
259
  log_ctx: JsonObject,
249
260
  ) -> IngestOutcome:
250
- """Kafka 发送(失败 502;占位保留自愈)→ overwrite 摘要写 → 成功日志。"""
261
+ """Kafka 发送 → 失败 502(on_send_failed,默认占位保留自愈)→ 成功钩子
262
+ → overwrite 摘要写 → 成功日志。"""
251
263
  binding = self.binding
252
- producer = self._require_producer()
253
264
  entity = str(binding.entity_key(record))
254
265
  slot = str(binding.slot_key(record))
255
266
  key = (
@@ -263,30 +274,17 @@ class IngestGateway(Generic[IngestRecordT]):
263
274
  _iso_z(received_at),
264
275
  source,
265
276
  )
266
- t0 = time.monotonic()
267
- try:
268
- await producer.send(key=key, message=message)
269
- except Exception as e:
270
- self._metrics.produce_failure.record()
271
- logger.error(
272
- "kafka_send_failed",
273
- **log_ctx,
274
- error=str(e),
275
- source=source,
276
- error_code=binding.kafka_unavailable_code,
277
- )
277
+ if not await self._produce(
278
+ record, key=key, message=message, log_ctx=log_ctx, source=source
279
+ ):
278
280
  return IngestOutcome.kafka_unavailable(
279
281
  error_code=binding.kafka_unavailable_code,
280
282
  detail=binding.kafka_unavailable_detail,
281
283
  )
282
- self._metrics.produce_success.record()
283
- self._metrics.produce_latency.record_latency(
284
- (time.monotonic() - t0) * 1000.0 # send 调用到 broker 确认耗时(毫秒)
284
+ cache_updated = (
285
+ await self._overwrite_summary_write(record) if overwrite else True
285
286
  )
286
287
 
287
- # ---- overwrite 路径:Kafka 成功后幂等摘要写(实现内自定重试)----
288
- cache_updated = await self._admission.on_accepted(record) if overwrite else True
289
-
290
288
  logger.info(
291
289
  "ingest_request",
292
290
  **(
@@ -300,6 +298,68 @@ class IngestGateway(Generic[IngestRecordT]):
300
298
  )
301
299
  return IngestOutcome.accepted(received_at, cache_updated=cache_updated)
302
300
 
301
+ async def _produce(
302
+ self,
303
+ record: IngestRecordT,
304
+ *,
305
+ key: str,
306
+ message: JsonObject,
307
+ log_ctx: JsonObject,
308
+ source: str,
309
+ ) -> bool:
310
+ """Kafka 发送 + 指标 + 发送结果钩子。False = 失败(已记录日志与钩子)。"""
311
+ producer = self._require_producer()
312
+ t0 = time.monotonic()
313
+ try:
314
+ await producer.send(key=key, message=message)
315
+ except Exception as e:
316
+ self._metrics.produce_failure.record()
317
+ logger.error(
318
+ "kafka_send_failed",
319
+ **log_ctx,
320
+ error=str(e),
321
+ source=source,
322
+ error_code=self.binding.kafka_unavailable_code,
323
+ )
324
+ await self._notify_send_failed(record)
325
+ return False
326
+ self._metrics.produce_success.record()
327
+ self._metrics.produce_latency.record_latency(
328
+ (time.monotonic() - t0) * 1000.0 # send 调用到 broker 确认耗时(毫秒)
329
+ )
330
+ await self._notify_send_success(record)
331
+ return True
332
+
333
+ async def _notify_send_success(self, record: IngestRecordT) -> None:
334
+ """每次发送成功的通知钩子(best-effort:钩子异常不影响已成功的请求)。"""
335
+ hook = _optional_hook(self._admission, "on_send_success")
336
+ if hook is None:
337
+ return
338
+ try:
339
+ await hook(record)
340
+ except Exception as e:
341
+ logger.warning("admission_send_success_hook_failed", error=str(e))
342
+
343
+ async def _notify_send_failed(self, record: IngestRecordT) -> None:
344
+ """发送失败钩子(best-effort:默认保留占位,实现可释放换取立即重发)。"""
345
+ hook = _optional_hook(self._admission, "on_send_failed")
346
+ if hook is None:
347
+ return
348
+ try:
349
+ await hook(record)
350
+ except Exception as e:
351
+ logger.warning("admission_send_failed_hook_failed", error=str(e))
352
+
353
+ async def _overwrite_summary_write(self, record: IngestRecordT) -> bool:
354
+ """overwrite 路径摘要写;旧版策略回退 on_accepted(向后兼容)。"""
355
+ hook = _optional_hook(self._admission, "on_overwrite_accepted")
356
+ if hook is None:
357
+ legacy = _optional_hook(self._admission, "on_accepted")
358
+ if legacy is None:
359
+ return True
360
+ return bool(await legacy(record))
361
+ return bool(await hook(record))
362
+
303
363
  def _require_producer(self) -> KafkaProducerService:
304
364
  """start() 完成前不可达;与 KafkaProducerService 未启动语义一致。"""
305
365
  if self._producer is None:
@@ -97,9 +97,21 @@ class AdmissionPolicy(Protocol[RecordT]):
97
97
  overwrite=True 时策略应跳过唯一性判定(409 确认后的完整重发)。"""
98
98
  ...
99
99
 
100
- async def on_accepted(self, record: RecordT) -> bool:
101
- """Kafka 发送成功后:占位/摘要写(失败语义由实现自定)。
102
- 返回 False 表示"缓存未反映本次记录"(映射到 cache_updated=false)。"""
100
+ async def on_send_success(self, record: RecordT) -> None:
101
+ """每次 Kafka 发送成功(broker ack)后调用(含 overwrite 路径;通知型)。
102
+ 非 overwrite 路径占位已在 admit 写入,默认无需动作;实现可做缓存续期等。"""
103
+ ...
104
+
105
+ async def on_send_failed(self, record: RecordT) -> None:
106
+ """Kafka 发送失败后调用(此时占位可能已写入)。
107
+ 框架默认语义是保留占位(防模糊失败重复,靠 TTL/回源自愈);
108
+ 实现可在此释放占位换取"立即可重发",自担重复风险。"""
109
+ ...
110
+
111
+ async def on_overwrite_accepted(self, record: RecordT) -> bool:
112
+ """仅 overwrite 路径、Kafka 成功后:幂等摘要写(失败语义由实现自定)。
113
+ 返回 False 表示"缓存未反映本次记录"(映射到 cache_updated=false)。
114
+ 兼容:旧版策略只实现 on_accepted 时,框架按本钩子语义回退调用。"""
103
115
  ...
104
116
 
105
117
  async def on_persisted(self, record: RecordT) -> None:
@@ -27,7 +27,8 @@ IngestRecordT = TypeVar("IngestRecordT", bound=BaseModel)
27
27
  @dataclass
28
28
  class IngestBinding(Generic[IngestRecordT]):
29
29
  """接收侧机制绑定:使用方完成 Schema 校验后,网关执行
30
- 背压 → 准入 → Kafka 发送 → on_accepted 编排。
30
+ 背压 → 准入 → Kafka 发送 → 发送结果钩子(on_send_success/on_send_failed/
31
+ on_overwrite_accepted)编排。
31
32
 
32
33
  路由/URL/鉴权/OpenAPI/响应模型等 HTTP 呈现职责归使用方适配层,
33
34
  本声明只承载机制所需的策略钩子与错误码契约。
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes