streamgate 0.1.0__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.
- streamgate/__init__.py +214 -0
- streamgate/_optional.py +38 -0
- streamgate/cache/__init__.py +0 -0
- streamgate/cache/existence.py +233 -0
- streamgate/config.py +150 -0
- streamgate/consumer/__init__.py +0 -0
- streamgate/consumer/classifier.py +139 -0
- streamgate/consumer/dlq.py +375 -0
- streamgate/consumer/loop.py +649 -0
- streamgate/consumer/runner.py +233 -0
- streamgate/db/__init__.py +0 -0
- streamgate/db/backfill.py +145 -0
- streamgate/db/dialects/__init__.py +0 -0
- streamgate/db/dialects/mssql.py +126 -0
- streamgate/db/dialects/sqlite.py +57 -0
- streamgate/db/engines.py +113 -0
- streamgate/db/upsert.py +205 -0
- streamgate/ingest/__init__.py +0 -0
- streamgate/ingest/admission/__init__.py +0 -0
- streamgate/ingest/admission/no_admission.py +37 -0
- streamgate/ingest/admission/redis_existence.py +489 -0
- streamgate/ingest/gateway.py +328 -0
- streamgate/ingest/producer.py +299 -0
- streamgate/obs/__init__.py +0 -0
- streamgate/obs/logging.py +43 -0
- streamgate/obs/metrics.py +30 -0
- streamgate/protocols.py +355 -0
- streamgate/resilience/__init__.py +0 -0
- streamgate/resilience/backpressure.py +370 -0
- streamgate/resilience/health.py +266 -0
- streamgate/specs.py +133 -0
- streamgate/transport/__init__.py +0 -0
- streamgate/transport/codec.py +62 -0
- streamgate/transport/kafka.py +194 -0
- streamgate-0.1.0.dist-info/METADATA +189 -0
- streamgate-0.1.0.dist-info/RECORD +38 -0
- streamgate-0.1.0.dist-info/WHEEL +4 -0
- streamgate-0.1.0.dist-info/licenses/LICENSE +21 -0
streamgate/__init__.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""streamgate 公共 API 出口:管道机制内核(admission + transport + resilience + sink)。
|
|
2
|
+
|
|
3
|
+
定位:条件接收 → 可靠投递 → 可插拔落地的数据管道框架。
|
|
4
|
+
- 机制归包:准入(唯一性契约:存在性判定+原子占位+冷回源)、背压/测活/自愈
|
|
5
|
+
(重连、paused、DLQ 二分、位点)、Kafka 收发、批量缓冲、优雅停机
|
|
6
|
+
- 策略归使用方:entity/slot/summary、Schema 校验、落地目标(sink/upserts/on_record)
|
|
7
|
+
- 呈现归使用方:HTTP 路由/鉴权/OpenAPI、健康端点暴露(包内零 fastapi/uvicorn)
|
|
8
|
+
|
|
9
|
+
Tier 0 声明式(90% 使用方):IngestBinding + ConsumeSpec/Upsert
|
|
10
|
+
→ IngestGateway(接收内核)/ ConsumerWorker(消费内核)
|
|
11
|
+
Tier 1 组件替换:protocols.py 中的协议 + 下方内置实现
|
|
12
|
+
Tier 2 逃生口:ConsumeSpec.on_record / ConsumeContext(只读快照)
|
|
13
|
+
|
|
14
|
+
使用方只允许 import 本模块,不得深入内部子模块
|
|
15
|
+
(import-linter 门禁强制)。
|
|
16
|
+
|
|
17
|
+
依赖分层(机制进核心,策略进 extras):redis / httpx 是可选策略实现的载体,
|
|
18
|
+
对应导出符号经模块级 __getattr__(PEP 562)惰性装载——裸装可正常 import 本模块;
|
|
19
|
+
访问未安装 extras 的门控符号时抛出带安装指引的 ImportError。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from importlib import import_module
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from streamgate._optional import require_optional
|
|
26
|
+
from streamgate.config import (
|
|
27
|
+
BackpressureConfig,
|
|
28
|
+
ConsumerConfig,
|
|
29
|
+
DbConfig,
|
|
30
|
+
KafkaConfig,
|
|
31
|
+
RedisConfig,
|
|
32
|
+
)
|
|
33
|
+
from streamgate.consumer.classifier import (
|
|
34
|
+
FailureCategory,
|
|
35
|
+
add_failure_rule,
|
|
36
|
+
classify_write_failure,
|
|
37
|
+
)
|
|
38
|
+
from streamgate.consumer.dlq import (
|
|
39
|
+
BisectOutcome,
|
|
40
|
+
BufferedMessage,
|
|
41
|
+
DlqProducer,
|
|
42
|
+
DlqSendError,
|
|
43
|
+
QuarantineRequest,
|
|
44
|
+
locate_and_write,
|
|
45
|
+
)
|
|
46
|
+
from streamgate.consumer.loop import ConsumeRuntime, consume_loop
|
|
47
|
+
from streamgate.consumer.runner import ConsumerWorker
|
|
48
|
+
from streamgate.db.backfill import SqlBackfill
|
|
49
|
+
from streamgate.db.engines import (
|
|
50
|
+
async_session_factory,
|
|
51
|
+
create_read_engine,
|
|
52
|
+
create_write_engine,
|
|
53
|
+
)
|
|
54
|
+
from streamgate.db.upsert import UpsertWriter
|
|
55
|
+
from streamgate.ingest.admission.no_admission import NoAdmission
|
|
56
|
+
from streamgate.ingest.gateway import IngestGateway
|
|
57
|
+
from streamgate.ingest.producer import KafkaProducerService
|
|
58
|
+
from streamgate.obs.logging import configure_logger, logger
|
|
59
|
+
from streamgate.obs.metrics import LoggingMetricsSink, MetricsSink
|
|
60
|
+
from streamgate.protocols import (
|
|
61
|
+
AdmissionPolicy,
|
|
62
|
+
AllowAllSignal,
|
|
63
|
+
BackfillSource,
|
|
64
|
+
BackpressureSignal,
|
|
65
|
+
BackpressureSnapshot,
|
|
66
|
+
ConsumeContext,
|
|
67
|
+
Decision,
|
|
68
|
+
DecisionKind,
|
|
69
|
+
Envelope,
|
|
70
|
+
IngestOutcome,
|
|
71
|
+
JsonObject,
|
|
72
|
+
MessageCodec,
|
|
73
|
+
NoBackfill,
|
|
74
|
+
OutcomeKind,
|
|
75
|
+
ProbeResult,
|
|
76
|
+
RecordHandler,
|
|
77
|
+
RecordWriter,
|
|
78
|
+
RejectInfo,
|
|
79
|
+
WriteResult,
|
|
80
|
+
)
|
|
81
|
+
from streamgate.resilience.health import (
|
|
82
|
+
ConsumerHealthResponse,
|
|
83
|
+
IngestHealthResponse,
|
|
84
|
+
collect_consumer_health,
|
|
85
|
+
collect_ingest_health,
|
|
86
|
+
)
|
|
87
|
+
from streamgate.specs import ConsumeSpec, IngestBinding, IngestRecordT, Upsert
|
|
88
|
+
from streamgate.transport.codec import JsonEnvelopeCodec
|
|
89
|
+
from streamgate.transport.kafka import KafkaConsumerService
|
|
90
|
+
|
|
91
|
+
if TYPE_CHECKING:
|
|
92
|
+
# extras 门控符号:运行时经 __getattr__ 惰性装载(见模块 docstring)
|
|
93
|
+
from streamgate.cache.existence import EMPTY_FIELD, RedisExistenceCache
|
|
94
|
+
from streamgate.ingest.admission.redis_existence import (
|
|
95
|
+
EntitySlots,
|
|
96
|
+
ExistenceUnavailableError,
|
|
97
|
+
RedisExistenceAdmission,
|
|
98
|
+
RedisExistenceAdmissionConfig,
|
|
99
|
+
SlotSource,
|
|
100
|
+
UndeterminedReason,
|
|
101
|
+
)
|
|
102
|
+
from streamgate.resilience.backpressure import HttpProbeSignal
|
|
103
|
+
|
|
104
|
+
__version__ = "0.1.0"
|
|
105
|
+
|
|
106
|
+
# extras 门控导出表:符号 → (来源模块, 顶层依赖名)。
|
|
107
|
+
# 裸装访问这些符号时抛 ImportError(含 pip install streamgate[extra] 指引)。
|
|
108
|
+
_EXTRA_EXPORTS: dict[str, tuple[str, str]] = {
|
|
109
|
+
"EMPTY_FIELD": ("streamgate.cache.existence", "redis"),
|
|
110
|
+
"RedisExistenceCache": ("streamgate.cache.existence", "redis"),
|
|
111
|
+
"EntitySlots": ("streamgate.ingest.admission.redis_existence", "redis"),
|
|
112
|
+
"ExistenceUnavailableError": (
|
|
113
|
+
"streamgate.ingest.admission.redis_existence",
|
|
114
|
+
"redis",
|
|
115
|
+
),
|
|
116
|
+
"RedisExistenceAdmission": (
|
|
117
|
+
"streamgate.ingest.admission.redis_existence",
|
|
118
|
+
"redis",
|
|
119
|
+
),
|
|
120
|
+
"RedisExistenceAdmissionConfig": (
|
|
121
|
+
"streamgate.ingest.admission.redis_existence",
|
|
122
|
+
"redis",
|
|
123
|
+
),
|
|
124
|
+
"SlotSource": ("streamgate.ingest.admission.redis_existence", "redis"),
|
|
125
|
+
"UndeterminedReason": ("streamgate.ingest.admission.redis_existence", "redis"),
|
|
126
|
+
"HttpProbeSignal": ("streamgate.resilience.backpressure", "httpx"),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def __getattr__(name: str) -> object:
|
|
131
|
+
"""PEP 562 惰性装载:extras 门控符号按需导入,其余符号维持 AttributeError。"""
|
|
132
|
+
target = _EXTRA_EXPORTS.get(name)
|
|
133
|
+
if target is None:
|
|
134
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
135
|
+
module_name, dependency = target
|
|
136
|
+
require_optional(dependency)
|
|
137
|
+
value: object = getattr(import_module(module_name), name)
|
|
138
|
+
globals()[name] = value # 缓存:后续访问不再走 __getattr__
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def __dir__() -> list[str]:
|
|
143
|
+
return sorted(__all__)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"AdmissionPolicy",
|
|
148
|
+
"AllowAllSignal",
|
|
149
|
+
"BackfillSource",
|
|
150
|
+
"BackpressureConfig",
|
|
151
|
+
"BackpressureSignal",
|
|
152
|
+
"BackpressureSnapshot",
|
|
153
|
+
"BisectOutcome",
|
|
154
|
+
"EntitySlots",
|
|
155
|
+
"SlotSource",
|
|
156
|
+
"BufferedMessage",
|
|
157
|
+
"ConsumerConfig",
|
|
158
|
+
"ConsumerHealthResponse",
|
|
159
|
+
"ConsumerWorker",
|
|
160
|
+
"ConsumeContext",
|
|
161
|
+
"ConsumeRuntime",
|
|
162
|
+
"ConsumeSpec",
|
|
163
|
+
"DbConfig",
|
|
164
|
+
"Decision",
|
|
165
|
+
"DecisionKind",
|
|
166
|
+
"DlqProducer",
|
|
167
|
+
"DlqSendError",
|
|
168
|
+
"EMPTY_FIELD",
|
|
169
|
+
"Envelope",
|
|
170
|
+
"ExistenceUnavailableError",
|
|
171
|
+
"FailureCategory",
|
|
172
|
+
"HttpProbeSignal",
|
|
173
|
+
"IngestBinding",
|
|
174
|
+
"IngestGateway",
|
|
175
|
+
"IngestHealthResponse",
|
|
176
|
+
"IngestOutcome",
|
|
177
|
+
"IngestRecordT",
|
|
178
|
+
"JsonObject",
|
|
179
|
+
"JsonEnvelopeCodec",
|
|
180
|
+
"KafkaConfig",
|
|
181
|
+
"KafkaConsumerService",
|
|
182
|
+
"KafkaProducerService",
|
|
183
|
+
"LoggingMetricsSink",
|
|
184
|
+
"MessageCodec",
|
|
185
|
+
"MetricsSink",
|
|
186
|
+
"NoAdmission",
|
|
187
|
+
"NoBackfill",
|
|
188
|
+
"OutcomeKind",
|
|
189
|
+
"ProbeResult",
|
|
190
|
+
"QuarantineRequest",
|
|
191
|
+
"RecordHandler",
|
|
192
|
+
"RecordWriter",
|
|
193
|
+
"RedisExistenceAdmission",
|
|
194
|
+
"RedisExistenceAdmissionConfig",
|
|
195
|
+
"RedisExistenceCache",
|
|
196
|
+
"RedisConfig",
|
|
197
|
+
"RejectInfo",
|
|
198
|
+
"SqlBackfill",
|
|
199
|
+
"UndeterminedReason",
|
|
200
|
+
"Upsert",
|
|
201
|
+
"UpsertWriter",
|
|
202
|
+
"WriteResult",
|
|
203
|
+
"add_failure_rule",
|
|
204
|
+
"async_session_factory",
|
|
205
|
+
"classify_write_failure",
|
|
206
|
+
"collect_consumer_health",
|
|
207
|
+
"collect_ingest_health",
|
|
208
|
+
"configure_logger",
|
|
209
|
+
"consume_loop",
|
|
210
|
+
"create_read_engine",
|
|
211
|
+
"create_write_engine",
|
|
212
|
+
"locate_and_write",
|
|
213
|
+
"logger",
|
|
214
|
+
]
|
streamgate/_optional.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""可选 extras 依赖装载点(惰性导入,C6 门禁基础设施)。
|
|
2
|
+
|
|
3
|
+
分层约定:redis / httpx 及各类数据库驱动是"可选策略实现"的载体,
|
|
4
|
+
一律不进入模块顶层 import;使用点经本模块校验后再局部导入,
|
|
5
|
+
裸装(无任何 extras)时 `import streamgate` 零第三方可选依赖触达。
|
|
6
|
+
|
|
7
|
+
对外契约:缺失 extras 时抛出带安装指引的 ImportError
|
|
8
|
+
(而非裸 ModuleNotFoundError),指引文案与 pyproject 的
|
|
9
|
+
[project.optional-dependencies] 保持一致。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
from importlib import import_module
|
|
14
|
+
|
|
15
|
+
# 顶层依赖名 → 安装指引(extra 名与 pyproject 可选依赖组逐字一致)
|
|
16
|
+
_EXTRA_HINTS: dict[str, str] = {
|
|
17
|
+
"redis": "streamgate[redis]",
|
|
18
|
+
"httpx": "streamgate[http-probe]",
|
|
19
|
+
"aiosqlite": "streamgate[sqlite]",
|
|
20
|
+
"aioodbc": "streamgate[mssql]",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_optional(dotted: str) -> None:
|
|
25
|
+
"""校验可选依赖可用,缺失即抛 ImportError(含 pip 安装指引)。
|
|
26
|
+
|
|
27
|
+
dotted 为目标模块点路径(如 "redis.asyncio"),指引按顶层包名匹配。
|
|
28
|
+
通过校验后由调用方在局部作用域执行真实 import(保留完整类型推导)。
|
|
29
|
+
"""
|
|
30
|
+
top = dotted.split(".", 1)[0]
|
|
31
|
+
if importlib.util.find_spec(top) is None:
|
|
32
|
+
hint = _EXTRA_HINTS.get(top)
|
|
33
|
+
guidance = f"Install it with: pip install {hint}" if hint else ""
|
|
34
|
+
raise ImportError(
|
|
35
|
+
f"Optional dependency '{top}' is required for this feature but is not "
|
|
36
|
+
f"installed. {guidance}".rstrip()
|
|
37
|
+
)
|
|
38
|
+
import_module(dotted)
|
|
File without changes
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""RedisExistenceCache:entity→slot→summary 的 Redis HASH 缓存(可插拔准入的参考存储)。
|
|
2
|
+
|
|
3
|
+
机制(摘要结构为 JSON dict):
|
|
4
|
+
- 原子占位(单 key Lua,Cluster 兼容):写入即无条件续期回满额(idle GC)
|
|
5
|
+
- 幂等摘要写:无条件 HSET + 续期(TTL 心跳)
|
|
6
|
+
- 空实体哨兵:仅 key 不存在时写入(防与占位竞态)
|
|
7
|
+
- fail-closed 与否是准入策略的参数,缓存层只如实报告错误
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
13
|
+
|
|
14
|
+
from streamgate._optional import require_optional
|
|
15
|
+
from streamgate.config import RedisConfig
|
|
16
|
+
from streamgate.obs.logging import logger
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
# redis 是 extras 依赖(streamgate[redis]):仅类型检查可见,运行时惰性装载
|
|
20
|
+
import redis.asyncio as aioredis
|
|
21
|
+
|
|
22
|
+
_T = TypeVar("_T")
|
|
23
|
+
|
|
24
|
+
_HEALTH_TIMEOUT_S = 2.0
|
|
25
|
+
|
|
26
|
+
# 哨兵 field 保留字:空实体标记。所有 HGETALL/HKEYS 消费者必须过滤它。
|
|
27
|
+
EMPTY_FIELD: str = "__empty__"
|
|
28
|
+
|
|
29
|
+
# 原子占位(单 key,Cluster 兼容):写入即无条件续期回满额(idle GC)
|
|
30
|
+
# KEYS[1]=existence key, ARGV=[slot, summary_json, ttl_seconds, empty_field]
|
|
31
|
+
# 返回 [1, ""] = 占位成功;[0, existing_json] = 已被占(竞态输家未写入,不续期)
|
|
32
|
+
_RESERVE_LUA = """
|
|
33
|
+
local existing = redis.call('HGET', KEYS[1], ARGV[1])
|
|
34
|
+
if existing then
|
|
35
|
+
return {0, existing}
|
|
36
|
+
end
|
|
37
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
38
|
+
redis.call('HDEL', KEYS[1], ARGV[4])
|
|
39
|
+
redis.call('EXPIRE', KEYS[1], ARGV[3])
|
|
40
|
+
return {1, ''}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# 幂等摘要写(overwrite 路径 / 消费端权威刷新):无条件 HSET + 无条件续期(idle GC,
|
|
44
|
+
# 消费端权威刷新即 TTL 心跳:key 过期时 DB 几乎必然已权威)
|
|
45
|
+
# KEYS[1]=existence key, ARGV=[slot, summary_json, ttl_seconds, empty_field]
|
|
46
|
+
_SET_SUMMARY_LUA = """
|
|
47
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
48
|
+
redis.call('HDEL', KEYS[1], ARGV[4])
|
|
49
|
+
redis.call('EXPIRE', KEYS[1], ARGV[3])
|
|
50
|
+
return 1
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# 空实体哨兵:仅 key 不存在时写入(哨兵闸门的原子保障,防与占位竞态)
|
|
54
|
+
# KEYS[1]=existence key, ARGV=[empty_ttl_seconds, empty_field]
|
|
55
|
+
_EMPTY_MARKER_LUA = """
|
|
56
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
57
|
+
redis.call('HSET', KEYS[1], ARGV[2], '{"empty":true}')
|
|
58
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
59
|
+
return 1
|
|
60
|
+
end
|
|
61
|
+
return 0
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_summary(raw: str) -> dict:
|
|
66
|
+
"""解析 field value;损坏数据不致命(返回空摘要,宁可多报 409)。"""
|
|
67
|
+
try:
|
|
68
|
+
parsed = json.loads(raw)
|
|
69
|
+
if isinstance(parsed, dict):
|
|
70
|
+
return parsed
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
logger.warning("cache_meta_corrupt", raw=raw[:100])
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def dumps_summary(summary: dict[str, object]) -> str:
|
|
78
|
+
return json.dumps(summary, ensure_ascii=False, default=str)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RedisExistenceCache:
|
|
82
|
+
def __init__(
|
|
83
|
+
self, config: RedisConfig, client: "aioredis.Redis | None" = None
|
|
84
|
+
) -> None:
|
|
85
|
+
self._config = config
|
|
86
|
+
self._injected = client
|
|
87
|
+
self._client: "aioredis.Redis | None" = None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def existence_ttl_seconds(self) -> int:
|
|
91
|
+
return self._config.existence_ttl_seconds
|
|
92
|
+
|
|
93
|
+
async def start(self) -> None:
|
|
94
|
+
if self._client is not None:
|
|
95
|
+
return
|
|
96
|
+
if self._injected is not None:
|
|
97
|
+
self._client = self._injected
|
|
98
|
+
return
|
|
99
|
+
# redis 为 extras 依赖:仅在此真正建连时装载(缺失抛带指引的 ImportError)
|
|
100
|
+
require_optional("redis.asyncio")
|
|
101
|
+
import redis.asyncio as aioredis
|
|
102
|
+
|
|
103
|
+
self._client = aioredis.from_url(
|
|
104
|
+
self._config.url,
|
|
105
|
+
socket_timeout=self._config.socket_timeout_ms / 1000,
|
|
106
|
+
socket_connect_timeout=self._config.socket_timeout_ms / 1000,
|
|
107
|
+
decode_responses=True,
|
|
108
|
+
)
|
|
109
|
+
logger.info("redis_client_created", url=self._config.url)
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
if self._client is None:
|
|
113
|
+
return
|
|
114
|
+
if self._injected is None: # 注入的客户端归调用方关闭
|
|
115
|
+
await self._client.aclose()
|
|
116
|
+
self._client = None
|
|
117
|
+
logger.info("redis_client_closed")
|
|
118
|
+
|
|
119
|
+
async def check_health_detail(self) -> tuple[bool, str | None]:
|
|
120
|
+
"""健康预检。返回 (是否可用, 错误信息);可用时 error 为 None。"""
|
|
121
|
+
if self._client is None:
|
|
122
|
+
return False, "redis client not started"
|
|
123
|
+
try:
|
|
124
|
+
await asyncio.wait_for(self._client.ping(), timeout=_HEALTH_TIMEOUT_S)
|
|
125
|
+
return True, None
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.debug("redis_health_check_failed", error=str(e))
|
|
128
|
+
return False, str(e)
|
|
129
|
+
|
|
130
|
+
async def check_health(self) -> bool:
|
|
131
|
+
ok, _ = await self.check_health_detail()
|
|
132
|
+
return ok
|
|
133
|
+
|
|
134
|
+
def existence_key(self, entity: str) -> str:
|
|
135
|
+
return f"{self._config.key_prefix}existence:{entity}"
|
|
136
|
+
|
|
137
|
+
# ---------- 内部工具 ----------
|
|
138
|
+
|
|
139
|
+
def _require_client(self) -> "aioredis.Redis":
|
|
140
|
+
if self._client is None:
|
|
141
|
+
raise RuntimeError("RedisExistenceCache not started, call start() first")
|
|
142
|
+
return self._client
|
|
143
|
+
|
|
144
|
+
async def _read(self, coro):
|
|
145
|
+
"""读操作统一超时(查询路径 socket_timeout_ms)。"""
|
|
146
|
+
return await asyncio.wait_for(coro, timeout=self._config.socket_timeout_ms / 1000)
|
|
147
|
+
|
|
148
|
+
async def _write(self, coro):
|
|
149
|
+
"""写操作统一超时(接收路径 recv_timeout_ms)。"""
|
|
150
|
+
return await asyncio.wait_for(coro, timeout=self._config.recv_timeout_ms / 1000)
|
|
151
|
+
|
|
152
|
+
# ---------- 读 ----------
|
|
153
|
+
|
|
154
|
+
async def get_field_meta(self, entity: str, slot: str) -> dict[str, object] | None:
|
|
155
|
+
raw = await self._read(self._require_client().hget(self.existence_key(entity), slot))
|
|
156
|
+
if raw is None:
|
|
157
|
+
return None
|
|
158
|
+
return parse_summary(raw)
|
|
159
|
+
|
|
160
|
+
async def key_exists(self, entity: str) -> bool:
|
|
161
|
+
return bool(await self._read(self._require_client().exists(self.existence_key(entity))))
|
|
162
|
+
|
|
163
|
+
async def get_entity_fields(self, entity: str) -> dict[str, dict[str, object]] | None:
|
|
164
|
+
raw: dict[str, str] = await self._read(
|
|
165
|
+
self._require_client().hgetall(self.existence_key(entity))
|
|
166
|
+
)
|
|
167
|
+
if not raw:
|
|
168
|
+
return None # Redis 中不存在只有 0 个 field 的 HASH,空 dict 即 key 不存在
|
|
169
|
+
return {
|
|
170
|
+
k: parse_summary(v) for k, v in raw.items() if k != EMPTY_FIELD
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def get_ttl(self, entity: str) -> int:
|
|
174
|
+
return int(await self._read(self._require_client().ttl(self.existence_key(entity))))
|
|
175
|
+
|
|
176
|
+
# ---------- 写 ----------
|
|
177
|
+
|
|
178
|
+
async def reserve_field(
|
|
179
|
+
self, entity: str, slot: str, summary: dict[str, object]
|
|
180
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
181
|
+
client = self._require_client()
|
|
182
|
+
script = client.register_script(_RESERVE_LUA)
|
|
183
|
+
result = await self._write(
|
|
184
|
+
script(
|
|
185
|
+
keys=[self.existence_key(entity)],
|
|
186
|
+
args=[slot, dumps_summary(summary), self._config.existence_ttl_seconds, EMPTY_FIELD],
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
reserved, existing = int(result[0]), result[1]
|
|
190
|
+
if reserved == 1:
|
|
191
|
+
return (True, None)
|
|
192
|
+
return (False, parse_summary(str(existing)))
|
|
193
|
+
|
|
194
|
+
async def write_summary(
|
|
195
|
+
self, entity: str, slot: str, summary: dict[str, object]
|
|
196
|
+
) -> None:
|
|
197
|
+
client = self._require_client()
|
|
198
|
+
script = client.register_script(_SET_SUMMARY_LUA)
|
|
199
|
+
await self._write(
|
|
200
|
+
script(
|
|
201
|
+
keys=[self.existence_key(entity)],
|
|
202
|
+
args=[slot, dumps_summary(summary), self._config.existence_ttl_seconds, EMPTY_FIELD],
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def write_entity_fields(
|
|
207
|
+
self, entity: str, slots: dict[str, dict[str, object]]
|
|
208
|
+
) -> None:
|
|
209
|
+
if not slots:
|
|
210
|
+
return
|
|
211
|
+
client = self._require_client()
|
|
212
|
+
key = self.existence_key(entity)
|
|
213
|
+
mapping = {slot: dumps_summary(meta) for slot, meta in slots.items()}
|
|
214
|
+
async with client.pipeline(transaction=False) as pipe:
|
|
215
|
+
# redis-py 桩的 FieldT 不变量约束无法匹配 Mapping[str, str](运行时合法)
|
|
216
|
+
pipe.hset(key, mapping=mapping) # type: ignore[reportArgumentType]
|
|
217
|
+
pipe.hdel(key, EMPTY_FIELD)
|
|
218
|
+
pipe.expire(key, self._config.existence_ttl_seconds) # 无条件续期
|
|
219
|
+
await self._write(pipe.execute())
|
|
220
|
+
|
|
221
|
+
async def write_empty_marker(self, entity: str) -> bool:
|
|
222
|
+
client = self._require_client()
|
|
223
|
+
script = client.register_script(_EMPTY_MARKER_LUA)
|
|
224
|
+
created = await self._write(
|
|
225
|
+
script(
|
|
226
|
+
keys=[self.existence_key(entity)],
|
|
227
|
+
args=[self._config.empty_existence_ttl_seconds, EMPTY_FIELD],
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return int(created) == 1
|
|
231
|
+
|
|
232
|
+
async def delete_entity(self, entity: str) -> None:
|
|
233
|
+
await self._write(self._require_client().delete(self.existence_key(entity)))
|
streamgate/config.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""streamgate 组件配置对象(每组件独立,Spec 内可覆写)。
|
|
2
|
+
|
|
3
|
+
配置键名与既有环境变量逐字对应
|
|
4
|
+
(KAFKA__* / CONSUMER__* / DB__* / REDIS__* / BACKPRESSURE__*),
|
|
5
|
+
调用方零配置迁移。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConsumerConfig(BaseModel):
|
|
12
|
+
# 必填:消费服务装配时校验(缺失即启动失败,不做项目专属默认)
|
|
13
|
+
group_id: str | None = None
|
|
14
|
+
batch_size: int = 100
|
|
15
|
+
batch_timeout_seconds: float = 5.0
|
|
16
|
+
max_retries: int = 3
|
|
17
|
+
retry_backoff_base: float = 1.0
|
|
18
|
+
# 重连/暂停恢复指数退避(对齐 ingest producer 的自愈策略,替代原硬编码 1.0/30.0)
|
|
19
|
+
reconnect_base_backoff_seconds: float = 1.0
|
|
20
|
+
reconnect_max_backoff_seconds: float = 30.0
|
|
21
|
+
max_poll_records: int = 500
|
|
22
|
+
session_timeout_ms: int = 30000
|
|
23
|
+
max_poll_interval_ms: int = 300000
|
|
24
|
+
auto_offset_reset: str = "earliest"
|
|
25
|
+
backlog_check_interval_seconds: float = 30.0 # 积压时长检查周期
|
|
26
|
+
# --- DLQ 隔离:消费端坏数据兜底 ---
|
|
27
|
+
dlq_enabled: bool = True # 总开关;false=回退 paused 旧行为(紧急逃生门)
|
|
28
|
+
dlq_send_retries: int = 3 # DLQ 单条发送总尝试次数(含首次;耗尽即批次转 paused)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KafkaConfig(BaseModel):
|
|
32
|
+
bootstrap_servers: str = "kafka:9092"
|
|
33
|
+
# 必填:producer/consumer 装配时校验(缺失即启动失败,不做项目专属默认)
|
|
34
|
+
topic: str | None = None
|
|
35
|
+
# 死信 topic(消费端隔离坏数据留档);启用 DLQ 时必填
|
|
36
|
+
dlq_topic: str | None = None
|
|
37
|
+
acks: str = "all"
|
|
38
|
+
request_timeout_ms: int = 10000
|
|
39
|
+
enable_idempotence: bool = True
|
|
40
|
+
# 自愈监控:周期性探活,不健康时销毁旧实例重建连接(覆盖启动期/运行期断线)
|
|
41
|
+
health_check_interval_seconds: float = 30.0 # 探活轮询周期
|
|
42
|
+
reconnect_base_backoff_seconds: float = 1.0 # 重连指数退避基数
|
|
43
|
+
reconnect_max_backoff_seconds: float = 30.0 # 重连退避上限
|
|
44
|
+
# R1 防抖:连续 send 失败 N 次才触发重建(默认 1=首次失败即重建;
|
|
45
|
+
# broker 秒级抖动时可调高减少误重建)
|
|
46
|
+
reconnect_failure_threshold: int = 1
|
|
47
|
+
# 探活口径:近窗口内有 send 失败即判定不健康(秒)
|
|
48
|
+
send_failure_window_seconds: float = 30.0
|
|
49
|
+
# R3 周期分级:异常/重建中高频探活间隔(秒);健康态仍用 health_check_interval_seconds
|
|
50
|
+
unhealthy_check_interval_seconds: float = 5.0
|
|
51
|
+
|
|
52
|
+
@field_validator("bootstrap_servers", mode="before")
|
|
53
|
+
@classmethod
|
|
54
|
+
def strip_url_scheme(cls, v: object) -> object:
|
|
55
|
+
if not isinstance(v, str):
|
|
56
|
+
return v
|
|
57
|
+
cleaned: list[str] = []
|
|
58
|
+
for part in v.split(","):
|
|
59
|
+
part = part.strip()
|
|
60
|
+
for scheme in ("http://", "https://", "kafka://"):
|
|
61
|
+
if part.lower().startswith(scheme):
|
|
62
|
+
part = part[len(scheme):]
|
|
63
|
+
break
|
|
64
|
+
cleaned.append(part)
|
|
65
|
+
return ",".join(cleaned)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DbConfig(BaseModel):
|
|
69
|
+
# 必填:声明 upserts/SqlBackfill 时在引擎创建单点校验
|
|
70
|
+
# (缺失即装配失败,不做项目专属默认;纯 Kafka 管道无需配置)。
|
|
71
|
+
# 驱动按连接串动态加载:sqlite 需 extras [sqlite],mssql 需 extras [mssql]。
|
|
72
|
+
connection_string: str | None = None
|
|
73
|
+
echo: bool = False
|
|
74
|
+
# --- L1 快速失败边界(异步落库链路加固)---
|
|
75
|
+
query_timeout_seconds: int = 5 # existence 回源驱动语句超时(MSSQL;须 <= query_wait_seconds)
|
|
76
|
+
query_wait_seconds: int = 8 # existence 回源调用层 wait_for
|
|
77
|
+
write_timeout_seconds: int = 20 # write_batch 驱动语句超时(MSSQL;须 <= write_wait_seconds)
|
|
78
|
+
write_wait_seconds: int = 25 # write_batch 调用层 wait_for
|
|
79
|
+
pool_timeout_seconds: int = 3 # 连接池获取超时(池耗尽快速失败)
|
|
80
|
+
cold_path_max_concurrency: int = 10 # 冷实体回源并发闸门(<=0 禁用;与读池宽度一致留余量)
|
|
81
|
+
cold_path_gate_retry_after_seconds: int = 1 # 闸门满时建议调用方的重试间隔(秒)
|
|
82
|
+
# 读连接池(ingest 进程 existence 回源专用):闸门默认 10,池总容量 20 留余量,
|
|
83
|
+
# 避免 check_health 等非回源占用与回源争抢触发 pool_timeout 快速失败。
|
|
84
|
+
read_pool_size: int = 10 # 读池固定连接数
|
|
85
|
+
read_pool_max_overflow: int = 10 # 读池溢出连接数(峰值余量)
|
|
86
|
+
# 写连接池(consumer 进程 write_batch 专用):默认值即原硬编码值(零行为变更)
|
|
87
|
+
write_pool_size: int = 10 # 写池固定连接数
|
|
88
|
+
write_pool_max_overflow: int = 20 # 写池溢出连接数
|
|
89
|
+
|
|
90
|
+
def require_connection_string(self) -> str:
|
|
91
|
+
"""连接串装配校验(引擎创建单点调用):None 即配置错误,含修复指引。"""
|
|
92
|
+
if self.connection_string is None:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"db connection string is required when upserts or SqlBackfill are "
|
|
95
|
+
"declared: set DbConfig.connection_string (env: DB__CONNECTION_STRING), "
|
|
96
|
+
"e.g. 'sqlite+aiosqlite:///./data/streamgate.db'"
|
|
97
|
+
)
|
|
98
|
+
return self.connection_string
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def dialect(self) -> str:
|
|
102
|
+
"""驱动方言标签(日志/分支用):sqlite | mssql。"""
|
|
103
|
+
return "sqlite" if "sqlite" in self.require_connection_string().lower() else "mssql"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def redacted_connection_string(self) -> str:
|
|
107
|
+
"""隐藏密码后的连接串(日志用,避免凭据入日志)。"""
|
|
108
|
+
s = self.require_connection_string()
|
|
109
|
+
if "://" not in s or "@" not in s:
|
|
110
|
+
return s
|
|
111
|
+
scheme, _, rest = s.partition("://")
|
|
112
|
+
userinfo, _, host = rest.rpartition("@")
|
|
113
|
+
if ":" in userinfo:
|
|
114
|
+
user, _, _ = userinfo.partition(":")
|
|
115
|
+
userinfo = f"{user}:***"
|
|
116
|
+
return f"{scheme}://{userinfo}@{host}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RedisConfig(BaseModel):
|
|
120
|
+
url: str = "redis://localhost:6379/0"
|
|
121
|
+
key_prefix: str = "streamgate:"
|
|
122
|
+
existence_ttl_seconds: int = 18000 # existence TTL 5h
|
|
123
|
+
empty_existence_ttl_seconds: int = 3600 # 空实体哨兵 TTL(短于 existence TTL)
|
|
124
|
+
socket_timeout_ms: int = 1000 # 查询/校验路径超时
|
|
125
|
+
recv_timeout_ms: int = 500 # 接收路径占位/摘要写超时
|
|
126
|
+
# ingest 自身 Redis 不可用时 fail-closed:校验路径/overwrite 路径/位置查询端点
|
|
127
|
+
# 全部返回错误而非静默降级(杜绝无占位接受与不完整查询结果);false=旧降级逃生门
|
|
128
|
+
fail_closed_on_unavailable: bool = True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class BackpressureConfig(BaseModel):
|
|
132
|
+
"""ingest 背压拒绝配置(consumption-backpressure)。
|
|
133
|
+
|
|
134
|
+
默认值与 existence TTL 联动:trip = existence_ttl/2 = 9000s(漏报窗口打开前拦截,
|
|
135
|
+
与积压告警线 backlog_age_warn 一致);recover = trip*80% = 7200s(磁滞防抖)。
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
enabled: bool = True # 总开关;False=完全放行(紧急回滚)
|
|
139
|
+
consumer_health_url: str = "http://localhost:9109/health" # consumer 健康接口地址
|
|
140
|
+
check_interval_seconds: float = 30.0 # 轮询周期(与积压检查周期一致)
|
|
141
|
+
timeout_seconds: float = 2.0 # 单次探活超时
|
|
142
|
+
probe_retries: int = 3 # 单周期内探活重试次数(不含首次);0=禁用
|
|
143
|
+
probe_retry_interval_seconds: float = 15.0 # 重试间隔(秒)
|
|
144
|
+
trip_seconds: float = 9000.0 # 触发阈值:积压超此值开始拒绝
|
|
145
|
+
recover_seconds: float = 7200.0 # 恢复阈值:积压回落至此值以下放行(磁滞)
|
|
146
|
+
retry_after_seconds: int = 60 # 拒绝时 Retry-After 头(秒)
|
|
147
|
+
fail_closed_on_unreachable: bool = True # 探活不可达时是否拒绝(fail-closed)
|
|
148
|
+
reject_on_any_degraded: bool = False # 旧行为逃生门:任何 degraded 即拒绝(恢复全量拒绝语义)
|
|
149
|
+
# R3 背压周期分级:REJECTING 态高频探活间隔(秒);OPEN 态仍用 check_interval_seconds
|
|
150
|
+
unhealthy_check_interval_seconds: float = 5.0
|
|
File without changes
|