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.
- {streamgate-0.4.0 → streamgate-0.5.0}/CHANGELOG.md +24 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/PKG-INFO +1 -1
- {streamgate-0.4.0 → streamgate-0.5.0}/README.zh-CN.md +5 -1
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/__init__.py +1 -1
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/admission.py +17 -3
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/in_memory.py +17 -4
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/no_admission.py +11 -1
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/gateway.py +83 -23
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/protocols.py +15 -3
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/specs.py +2 -1
- {streamgate-0.4.0 → streamgate-0.5.0}/.github/workflows/ci.yml +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/.github/workflows/release.yml +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/.gitignore +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/AGENTS.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/CONFIGURATION.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/CONTRIBUTING.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/LICENSE +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/docker-compose.yml +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/consume.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/health_server.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/models.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/http_probe/produce.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/consume.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/health_server.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/models.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/prod_pipeline/produce.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/consume.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/models.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/pure_pipeline/produce.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/models.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/redis_admission/produce.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/README.md +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/consume.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/models.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/examples/sqlite_sink/produce.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/pyproject.toml +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/config.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/classifier.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/dlq.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/loop.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/consumer/runner.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/_deps.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/mssql_sink/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/config.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/redis_admission/existence.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/mssql.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/_dialects/sqlite.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/backfill.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/classifier.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/config.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/engines.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sql_sink/upsert.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/contrib/sqlite_sink/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/admission/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/ingest/producer.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/logging.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/obs/metrics.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/backpressure.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/resilience/health.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/__init__.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/codec.py +0 -0
- {streamgate-0.4.0 → streamgate-0.5.0}/src/streamgate/transport/kafka.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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 发送) →
|
|
7
|
-
|
|
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
|
|
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 发送) →
|
|
22
|
-
|
|
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 确认后的完整重发:跳过唯一性判定(摘要由
|
|
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
|
|
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
|
|
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:传输无关的接收编排内核(背压 → 准入 → 发送 →
|
|
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 发送 →
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
101
|
-
"""Kafka
|
|
102
|
-
|
|
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 发送 →
|
|
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
|
|
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
|