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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""消费服务装配与生命周期(机制内核,无 HTTP 呈现)。
|
|
2
|
+
|
|
3
|
+
职责:init sink(可选)→ 启动缓存/consumer/DLQ → 消费循环 →
|
|
4
|
+
优雅停机(flush 缓冲、leave group、关资源)→ 健康快照。
|
|
5
|
+
健康数据经 health_snapshot() 暴露;端点实现归使用方。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import signal
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.ext.asyncio import (
|
|
12
|
+
AsyncEngine,
|
|
13
|
+
AsyncSession,
|
|
14
|
+
async_sessionmaker,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from streamgate.cache.existence import RedisExistenceCache
|
|
18
|
+
from streamgate.config import ConsumerConfig, DbConfig, KafkaConfig, RedisConfig
|
|
19
|
+
from streamgate.consumer.dlq import DlqProducer
|
|
20
|
+
from streamgate.consumer.loop import ConsumeRuntime, consume_loop
|
|
21
|
+
from streamgate.db.engines import async_session_factory, create_write_engine
|
|
22
|
+
from streamgate.db.upsert import UpsertWriter
|
|
23
|
+
from streamgate.obs.logging import logger
|
|
24
|
+
from streamgate.protocols import MessageCodec, RecordWriter
|
|
25
|
+
from streamgate.resilience.health import (
|
|
26
|
+
ConsumerHealthResponse,
|
|
27
|
+
collect_consumer_health,
|
|
28
|
+
)
|
|
29
|
+
from streamgate.specs import ConsumeSpec
|
|
30
|
+
from streamgate.transport.codec import JsonEnvelopeCodec
|
|
31
|
+
from streamgate.transport.kafka import KafkaConsumerService
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConsumerWorker:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
spec: ConsumeSpec,
|
|
38
|
+
*,
|
|
39
|
+
kafka_config: KafkaConfig,
|
|
40
|
+
consumer_config: ConsumerConfig,
|
|
41
|
+
db_config: DbConfig | None = None,
|
|
42
|
+
redis_config: RedisConfig | None = None,
|
|
43
|
+
cache: RedisExistenceCache | None = None,
|
|
44
|
+
writer: RecordWriter | None = None,
|
|
45
|
+
engine: AsyncEngine | None = None,
|
|
46
|
+
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
|
47
|
+
codec: MessageCodec | None = None,
|
|
48
|
+
existence_ttl_seconds: int | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.spec = spec
|
|
51
|
+
self._kafka_config = kafka_config
|
|
52
|
+
self._consumer_config = consumer_config
|
|
53
|
+
self._db_config = db_config
|
|
54
|
+
self._redis_config = redis_config
|
|
55
|
+
self._cache = cache
|
|
56
|
+
self._writer = writer
|
|
57
|
+
self._engine = engine
|
|
58
|
+
self._session_factory = session_factory
|
|
59
|
+
self._codec = codec or JsonEnvelopeCodec()
|
|
60
|
+
self._existence_ttl_seconds = (
|
|
61
|
+
existence_ttl_seconds
|
|
62
|
+
if existence_ttl_seconds is not None
|
|
63
|
+
else (redis_config.existence_ttl_seconds if redis_config is not None else 18000)
|
|
64
|
+
)
|
|
65
|
+
self.runtime: ConsumeRuntime | None = None
|
|
66
|
+
self._stop_event: asyncio.Event | None = None
|
|
67
|
+
|
|
68
|
+
# ---- 装配 ----
|
|
69
|
+
|
|
70
|
+
def _resolve_writer(self) -> RecordWriter | None:
|
|
71
|
+
"""落地目标解析:显式 sink 优先;upserts 非空时构建内置 UpsertWriter;
|
|
72
|
+
on_record 模式返回 None(无写侧)。"""
|
|
73
|
+
if self._writer is not None:
|
|
74
|
+
return self._writer
|
|
75
|
+
if not self.spec.upserts:
|
|
76
|
+
return None
|
|
77
|
+
if self._db_config is None:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"db_config is required when ConsumeSpec.upserts is set"
|
|
80
|
+
)
|
|
81
|
+
engine = self._engine or create_write_engine(self._db_config)
|
|
82
|
+
factory = self._session_factory or async_session_factory(engine)
|
|
83
|
+
return UpsertWriter(self._db_config, engine, factory, self.spec.upserts)
|
|
84
|
+
|
|
85
|
+
def _build_dlq(self) -> DlqProducer | None:
|
|
86
|
+
"""DLQ 隔离 producer(启动失败仅记日志,懒启动在 quarantine 兜底)。"""
|
|
87
|
+
enabled = (
|
|
88
|
+
self.spec.dlq
|
|
89
|
+
if self.spec.dlq is not None
|
|
90
|
+
else self._consumer_config.dlq_enabled
|
|
91
|
+
)
|
|
92
|
+
if not enabled:
|
|
93
|
+
return None
|
|
94
|
+
return DlqProducer(
|
|
95
|
+
self._kafka_config,
|
|
96
|
+
topic=self.spec.dlq_topic or None,
|
|
97
|
+
send_retries=self._consumer_config.dlq_send_retries,
|
|
98
|
+
message_type=self.spec.dlq_message_type,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def prepare(self) -> ConsumeRuntime:
|
|
102
|
+
"""初始化全部组件(幂等入口)。"""
|
|
103
|
+
if self.runtime is not None:
|
|
104
|
+
return self.runtime
|
|
105
|
+
|
|
106
|
+
writer = self._resolve_writer()
|
|
107
|
+
if writer is not None:
|
|
108
|
+
await writer.start()
|
|
109
|
+
|
|
110
|
+
cache = self._cache
|
|
111
|
+
if cache is not None:
|
|
112
|
+
await cache.start()
|
|
113
|
+
|
|
114
|
+
# 初始化 Kafka consumer(启动失败不 crash,交给消费循环重连)
|
|
115
|
+
consumer = KafkaConsumerService(
|
|
116
|
+
self._kafka_config,
|
|
117
|
+
self._consumer_config,
|
|
118
|
+
topic=self.spec.topic or None,
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
await consumer.start()
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(
|
|
124
|
+
"consumer_startup_failed_degraded",
|
|
125
|
+
error=str(e),
|
|
126
|
+
bootstrap_servers=self._kafka_config.bootstrap_servers,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
dlq = self._build_dlq()
|
|
130
|
+
if dlq is not None:
|
|
131
|
+
await dlq.start()
|
|
132
|
+
|
|
133
|
+
self.runtime = ConsumeRuntime(
|
|
134
|
+
spec=self.spec,
|
|
135
|
+
consumer_config=self._consumer_config,
|
|
136
|
+
writer=writer,
|
|
137
|
+
consumer=consumer,
|
|
138
|
+
codec=self._codec,
|
|
139
|
+
existence_ttl_seconds=self._existence_ttl_seconds,
|
|
140
|
+
persist_policy=self.spec.persist_policy,
|
|
141
|
+
cache=cache,
|
|
142
|
+
dlq=dlq,
|
|
143
|
+
)
|
|
144
|
+
return self.runtime
|
|
145
|
+
|
|
146
|
+
# ---- 停机 ----
|
|
147
|
+
|
|
148
|
+
def request_stop(self) -> None:
|
|
149
|
+
"""请求停机(信号处理/宿主调用)。"""
|
|
150
|
+
if self.runtime is not None:
|
|
151
|
+
self.runtime.running = False
|
|
152
|
+
if self._stop_event is not None:
|
|
153
|
+
self._stop_event.set()
|
|
154
|
+
|
|
155
|
+
def _register_stop_signals(
|
|
156
|
+
self, runtime: ConsumeRuntime, stop_event: asyncio.Event
|
|
157
|
+
) -> None:
|
|
158
|
+
"""注册停机信号处理(优雅停机)。"""
|
|
159
|
+
|
|
160
|
+
def _signal_handler() -> None:
|
|
161
|
+
logger.info("shutdown_signal_received")
|
|
162
|
+
runtime.running = False
|
|
163
|
+
stop_event.set()
|
|
164
|
+
|
|
165
|
+
loop = asyncio.get_running_loop()
|
|
166
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
167
|
+
try:
|
|
168
|
+
loop.add_signal_handler(sig, _signal_handler)
|
|
169
|
+
except NotImplementedError:
|
|
170
|
+
# Windows 不支持 add_signal_handler,用 signal.signal 兜底
|
|
171
|
+
signal.signal(sig, lambda s, f: _signal_handler())
|
|
172
|
+
|
|
173
|
+
# ---- 运行 ----
|
|
174
|
+
|
|
175
|
+
async def run(self) -> None:
|
|
176
|
+
"""独立进程入口:组件装配 + 信号处理 + 消费循环(阻塞至停机)。
|
|
177
|
+
|
|
178
|
+
健康 HTTP 等呈现不在此装配:宿主并行启动自己的暴露实现,
|
|
179
|
+
并在 run() 返回后自行关闭。
|
|
180
|
+
"""
|
|
181
|
+
runtime = await self.prepare()
|
|
182
|
+
|
|
183
|
+
stop_event = asyncio.Event()
|
|
184
|
+
self._stop_event = stop_event
|
|
185
|
+
self._register_stop_signals(runtime, stop_event)
|
|
186
|
+
|
|
187
|
+
consume_task = asyncio.create_task(consume_loop(runtime))
|
|
188
|
+
|
|
189
|
+
# 等待停机信号
|
|
190
|
+
await stop_event.wait()
|
|
191
|
+
|
|
192
|
+
# 等待消费循环退出(最多 30 秒)
|
|
193
|
+
try:
|
|
194
|
+
await asyncio.wait_for(consume_task, timeout=30.0)
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
logger.warning("shutdown_timeout_consume_loop_did_not_finish")
|
|
197
|
+
|
|
198
|
+
await self.shutdown()
|
|
199
|
+
logger.info("app_stopped")
|
|
200
|
+
|
|
201
|
+
async def shutdown(self) -> None:
|
|
202
|
+
"""关闭资源(幂等)。"""
|
|
203
|
+
runtime = self.runtime
|
|
204
|
+
if runtime is None:
|
|
205
|
+
return
|
|
206
|
+
if runtime.consumer is not None:
|
|
207
|
+
await runtime.consumer.stop()
|
|
208
|
+
if runtime.dlq is not None:
|
|
209
|
+
await runtime.dlq.stop()
|
|
210
|
+
if runtime.cache is not None:
|
|
211
|
+
await runtime.cache.close()
|
|
212
|
+
if runtime.writer is not None:
|
|
213
|
+
await runtime.writer.close()
|
|
214
|
+
|
|
215
|
+
# ---- 观测 ----
|
|
216
|
+
|
|
217
|
+
async def health_snapshot(self) -> ConsumerHealthResponse:
|
|
218
|
+
"""健康快照(数据归框架,暴露方式归使用方)。"""
|
|
219
|
+
runtime = self.runtime
|
|
220
|
+
if runtime is None:
|
|
221
|
+
return ConsumerHealthResponse(
|
|
222
|
+
status="stopped",
|
|
223
|
+
kafka="disconnected",
|
|
224
|
+
database=None,
|
|
225
|
+
redis=None,
|
|
226
|
+
pending_count=0,
|
|
227
|
+
lag=0,
|
|
228
|
+
quarantined_count=0,
|
|
229
|
+
)
|
|
230
|
+
return await collect_consumer_health(runtime)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = ["ConsumerWorker"]
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""回源实现:NoBackfill / SqlBackfill(BackfillSource 协议的内置实现)。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
8
|
+
|
|
9
|
+
from streamgate.obs.logging import logger
|
|
10
|
+
from streamgate.protocols import JsonObject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _jsonable(value: object) -> object:
|
|
14
|
+
"""datetime 实例/字符串统一为 ISO(与上游摘要模型序列化行为对齐:
|
|
15
|
+
原实现经 pydantic 包装,DB 驱动返回的 "YYYY-MM-DD HH:MM:SS" 字符串
|
|
16
|
+
会被解析后以 ISO 重新序列化;此处保持同一缓存格式)。"""
|
|
17
|
+
if isinstance(value, datetime):
|
|
18
|
+
return value.isoformat()
|
|
19
|
+
if (
|
|
20
|
+
isinstance(value, str)
|
|
21
|
+
and len(value) >= 19
|
|
22
|
+
and value[4] == "-"
|
|
23
|
+
and value[7] == "-"
|
|
24
|
+
):
|
|
25
|
+
try:
|
|
26
|
+
return datetime.fromisoformat(value).isoformat()
|
|
27
|
+
except ValueError:
|
|
28
|
+
return value
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SqlBackfill:
|
|
33
|
+
"""SQL 回源:整实体加载 slot→summary,同 slot 多行取"最新"一行。
|
|
34
|
+
|
|
35
|
+
order_columns 按优先级降序排列(如 ["tested_at", "seq_no"]),None 视为最小;
|
|
36
|
+
summary_columns: summary 键 → 列名(值为 None 表示输出固定 null)。
|
|
37
|
+
调用层超时(query_wait_seconds),超时抛 asyncio.TimeoutError。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
engine: AsyncEngine,
|
|
43
|
+
*,
|
|
44
|
+
table: str,
|
|
45
|
+
entity_column: str,
|
|
46
|
+
slot_column: str,
|
|
47
|
+
summary_columns: dict[str, str | None],
|
|
48
|
+
order_columns: list[str],
|
|
49
|
+
query_wait_seconds: float = 8.0,
|
|
50
|
+
component_label: str = "sql_backfill",
|
|
51
|
+
) -> None:
|
|
52
|
+
self._engine: AsyncEngine | None = engine
|
|
53
|
+
self._table = table
|
|
54
|
+
self._entity_column = entity_column
|
|
55
|
+
self._slot_column = slot_column
|
|
56
|
+
self._summary_columns = summary_columns
|
|
57
|
+
self._order_columns = list(order_columns)
|
|
58
|
+
self._query_wait_seconds = query_wait_seconds
|
|
59
|
+
self._label = component_label
|
|
60
|
+
# 行布局固定:slot, *order_columns, *extra_summary_columns
|
|
61
|
+
# (summary_columns 值为 None 的键不在 SELECT 中,恒输出 null)
|
|
62
|
+
self._extra_summary: list[tuple[str, str]] = [
|
|
63
|
+
(key, col) for key, col in summary_columns.items() if col is not None
|
|
64
|
+
]
|
|
65
|
+
self._null_summary_keys = [
|
|
66
|
+
key for key, col in summary_columns.items() if col is None
|
|
67
|
+
]
|
|
68
|
+
select_cols = [slot_column, *order_columns, *(col for _, col in self._extra_summary)]
|
|
69
|
+
# 列名/表名来自使用方受控配置,非用户输入,无注入面
|
|
70
|
+
self._sql = text(
|
|
71
|
+
f"SELECT {', '.join(select_cols)} "
|
|
72
|
+
f"FROM {table} WHERE {entity_column} = :e"
|
|
73
|
+
)
|
|
74
|
+
logger.info(
|
|
75
|
+
f"{component_label}_initialized",
|
|
76
|
+
engine="sqlite" if "sqlite" in str(getattr(engine, "url", "")).lower() else "mssql",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
if self._engine is None:
|
|
81
|
+
return
|
|
82
|
+
await self._engine.dispose()
|
|
83
|
+
self._engine = None
|
|
84
|
+
logger.info(f"{self._label}_closed")
|
|
85
|
+
|
|
86
|
+
async def check_health(self) -> bool:
|
|
87
|
+
ok, _ = await self.check_health_detail()
|
|
88
|
+
return ok
|
|
89
|
+
|
|
90
|
+
async def check_health_detail(self) -> tuple[bool, str | None]:
|
|
91
|
+
"""连接探测:返回 (是否可用, 错误信息);失败时错误信息供调用方 ERROR 日志。"""
|
|
92
|
+
if self._engine is None:
|
|
93
|
+
return False, "engine not initialized"
|
|
94
|
+
try:
|
|
95
|
+
async with self._engine.connect() as conn:
|
|
96
|
+
await conn.execute(text("SELECT 1"))
|
|
97
|
+
return True, None
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.debug(f"{self._label}_health_failed", error=str(e))
|
|
100
|
+
return False, str(e)
|
|
101
|
+
|
|
102
|
+
async def load(self, entity: str) -> dict[str, JsonObject]:
|
|
103
|
+
if self._engine is None:
|
|
104
|
+
raise RuntimeError("SqlBackfill not started, engine not injected")
|
|
105
|
+
try:
|
|
106
|
+
rows = await asyncio.wait_for(
|
|
107
|
+
self._fetch_rows(entity), timeout=self._query_wait_seconds
|
|
108
|
+
)
|
|
109
|
+
except asyncio.TimeoutError:
|
|
110
|
+
logger.error(
|
|
111
|
+
f"{self._label}_load_timeout",
|
|
112
|
+
entity=entity,
|
|
113
|
+
wait_seconds=self._query_wait_seconds,
|
|
114
|
+
)
|
|
115
|
+
raise
|
|
116
|
+
return self._merge_rows(rows)
|
|
117
|
+
|
|
118
|
+
async def _fetch_rows(self, entity: str) -> list[tuple[object, ...]]:
|
|
119
|
+
if self._engine is None:
|
|
120
|
+
raise RuntimeError("SqlBackfill not started, engine not injected")
|
|
121
|
+
async with self._engine.connect() as conn:
|
|
122
|
+
result = await conn.execute(self._sql, {"e": entity})
|
|
123
|
+
return [tuple(row) for row in result.fetchall()]
|
|
124
|
+
|
|
125
|
+
def _merge_rows(self, rows: list[tuple[object, ...]]) -> dict[str, JsonObject]:
|
|
126
|
+
"""存量重复行:每组 slot 取排序列最大的一行(幂等摘要语义)。
|
|
127
|
+
|
|
128
|
+
row 布局与 SELECT 列序一致:slot, *order_columns, *extra_summary_columns。
|
|
129
|
+
"""
|
|
130
|
+
n_order = len(self._order_columns)
|
|
131
|
+
best: dict[str, tuple[tuple[object, ...], JsonObject]] = {}
|
|
132
|
+
for row in rows:
|
|
133
|
+
slot = row[0]
|
|
134
|
+
if not isinstance(slot, str):
|
|
135
|
+
continue # 历史脏行防御(None/非字符串主键),不应出现
|
|
136
|
+
summary: JsonObject = {}
|
|
137
|
+
for key in self._null_summary_keys:
|
|
138
|
+
summary[key] = None
|
|
139
|
+
for i, (key, _col) in enumerate(self._extra_summary):
|
|
140
|
+
summary[key] = _jsonable(row[1 + n_order + i])
|
|
141
|
+
sort_values = row[1 : 1 + n_order]
|
|
142
|
+
sort_key = tuple((v is not None, v if v is not None else 0) for v in sort_values)
|
|
143
|
+
if slot not in best or sort_key > best[slot][0]:
|
|
144
|
+
best[slot] = (sort_key, summary)
|
|
145
|
+
return {slot: meta for slot, (_, meta) in best.items()}
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""MSSQL 方言:多行 VALUES + MERGE 幂等 upsert(按参数预算分块)。
|
|
2
|
+
|
|
3
|
+
SQLAlchemy 2.0 的 mssql 方言未提供 MERGE 构造,用 text() 写原生 SQL。
|
|
4
|
+
|
|
5
|
+
并发前提(生产实测教训,勿"简化"):
|
|
6
|
+
- HOLDLOCK 是并发正确性的必需项(序列化同键 MERGE,防止双方同时
|
|
7
|
+
判定 NOT MATCHED 而双插),不得移除;
|
|
8
|
+
- 外部写入方竞态可能在存量表中制造同键重复行,MERGE"target N 行匹配
|
|
9
|
+
全部 UPDATE"语义是对该现实的对冲,不得收紧为单行更新;
|
|
10
|
+
- 阻塞/死锁(1205 被选为 victim)由整事务超时与上层通用重试兜底,
|
|
11
|
+
事务回滚天然保原子性。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import String, TextClause, text
|
|
17
|
+
from sqlalchemy import inspect as sa_inspect
|
|
18
|
+
from sqlalchemy.dialects import mssql
|
|
19
|
+
from sqlmodel import SQLModel
|
|
20
|
+
|
|
21
|
+
from streamgate.protocols import JsonObject
|
|
22
|
+
|
|
23
|
+
# SQL Server 单语句参数上限约 2100,预留 sp_executesql 内部参数开销后的
|
|
24
|
+
# 安全预算;每块行数 = 预算 // 字段数,随字段数动态计算(不写死行数)。
|
|
25
|
+
SAFE_PARAM_BUDGET = 2000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mssql_cast_types(model: type[SQLModel]) -> dict[str, str]:
|
|
29
|
+
"""按表元数据生成各属性的 MSSQL CAST 目标类型(键为 Python 属性名)。
|
|
30
|
+
|
|
31
|
+
应对 B4:VALUES 行构造器中某列全为 NULL 参数时,类型推导可能给出与
|
|
32
|
+
目标列不匹配的类型,显式 CAST 固定每列类型。字符串列统一 NVARCHAR(max):
|
|
33
|
+
驱动(aioodbc/pyodbc)本就按 Unicode 声明字符串参数,与既有单行 MERGE
|
|
34
|
+
的比较语义一致,且规避 CAST 到 VARCHAR 时按库默认排序规则代码页转换
|
|
35
|
+
的字符损失风险;其余类型按 MSSQL 方言编译列元数据(FLOAT / DATETIME)。
|
|
36
|
+
"""
|
|
37
|
+
dialect = mssql.dialect()
|
|
38
|
+
types: dict[str, str] = {}
|
|
39
|
+
for prop in sa_inspect(model).column_attrs:
|
|
40
|
+
col_type = prop.columns[0].type
|
|
41
|
+
# SQLModel 字符串列是 TypeDecorator(AutoString),需下钻 impl 判定字符串族
|
|
42
|
+
impl = getattr(col_type, "impl", col_type)
|
|
43
|
+
if isinstance(impl, type):
|
|
44
|
+
is_string = issubclass(impl, String)
|
|
45
|
+
else:
|
|
46
|
+
is_string = isinstance(impl, String)
|
|
47
|
+
types[prop.key] = "NVARCHAR(max)" if is_string else col_type.compile(dialect)
|
|
48
|
+
return types
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def chunk_by_param_budget(
|
|
52
|
+
rows: list[JsonObject],
|
|
53
|
+
field_count: int,
|
|
54
|
+
) -> Iterator[list[JsonObject]]:
|
|
55
|
+
"""按参数预算分块:每块行数 = SAFE_PARAM_BUDGET // 字段数。"""
|
|
56
|
+
max_rows = max(1, SAFE_PARAM_BUDGET // field_count)
|
|
57
|
+
for i in range(0, len(rows), max_rows):
|
|
58
|
+
yield rows[i : i + max_rows]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dedup_rows(
|
|
62
|
+
rows: list[JsonObject],
|
|
63
|
+
keys: list[str],
|
|
64
|
+
) -> list[JsonObject]:
|
|
65
|
+
"""按 upsert 键去重,保留最后一条。
|
|
66
|
+
|
|
67
|
+
防止多行 VALUES + MERGE 因 source 内部重复(M 行 source 匹配同一行
|
|
68
|
+
target,MERGE 报 "attempted to UPDATE or DELETE the same row more
|
|
69
|
+
than once")导致整批失败成为毒批。业务上一键一条,正常不会重复;
|
|
70
|
+
此为防御性去重。
|
|
71
|
+
"""
|
|
72
|
+
seen: dict[tuple[object, ...], JsonObject] = {}
|
|
73
|
+
for d in rows:
|
|
74
|
+
key = tuple(d.get(k) for k in keys)
|
|
75
|
+
seen[key] = d # 后者覆盖前者
|
|
76
|
+
return list(seen.values())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_multirow_merge(
|
|
80
|
+
table_name: str,
|
|
81
|
+
fields: list[str],
|
|
82
|
+
keys: list[str],
|
|
83
|
+
column_names: dict[str, str],
|
|
84
|
+
cast_types: dict[str, str],
|
|
85
|
+
rows: list[JsonObject],
|
|
86
|
+
) -> tuple[TextClause, JsonObject]:
|
|
87
|
+
"""构建多行 VALUES + MERGE 语句及参数(分块后的一块)。
|
|
88
|
+
|
|
89
|
+
- 参数命名 r{行号}_{属性名},每行每列唯一;字段名来自模型元数据,无注入面;
|
|
90
|
+
- 列名经 column_names 映射(属性名 → DB 列名,保留原始列名的大小写与特殊字符);
|
|
91
|
+
- ON / UPDATE / INSERT 子句与生产验证过的单行 MERGE 模板逐字一致。
|
|
92
|
+
"""
|
|
93
|
+
def _col(field: str) -> str:
|
|
94
|
+
return column_names.get(field, field)
|
|
95
|
+
|
|
96
|
+
values_rows: list[str] = []
|
|
97
|
+
params: JsonObject = {}
|
|
98
|
+
for i, row in enumerate(rows):
|
|
99
|
+
casts: list[str] = []
|
|
100
|
+
for field in fields:
|
|
101
|
+
param = f"r{i}_{field}"
|
|
102
|
+
params[param] = row.get(field)
|
|
103
|
+
casts.append(f"CAST(:{param} AS {cast_types[field]})")
|
|
104
|
+
values_rows.append(f"({', '.join(casts)})")
|
|
105
|
+
|
|
106
|
+
source_rows = ",\n ".join(values_rows)
|
|
107
|
+
source_aliases = ", ".join(f"[{_col(f)}]" for f in fields)
|
|
108
|
+
update_fields = [f for f in fields if f not in keys]
|
|
109
|
+
set_clause = ", ".join(f"t.[{_col(f)}] = s.[{_col(f)}]" for f in update_fields)
|
|
110
|
+
insert_col_list = ", ".join(f"[{_col(f)}]" for f in fields)
|
|
111
|
+
insert_val_list = ", ".join(f"s.[{_col(f)}]" for f in fields)
|
|
112
|
+
on_clause = " AND ".join(f"t.[{_col(k)}] = s.[{_col(k)}]" for k in keys)
|
|
113
|
+
|
|
114
|
+
sql = text(
|
|
115
|
+
f"""
|
|
116
|
+
MERGE INTO {table_name} WITH (HOLDLOCK) AS t
|
|
117
|
+
USING (VALUES
|
|
118
|
+
{source_rows}
|
|
119
|
+
) AS s({source_aliases})
|
|
120
|
+
ON {on_clause}
|
|
121
|
+
WHEN MATCHED THEN UPDATE SET {set_clause}
|
|
122
|
+
WHEN NOT MATCHED THEN INSERT ({insert_col_list})
|
|
123
|
+
VALUES ({insert_val_list});
|
|
124
|
+
"""
|
|
125
|
+
)
|
|
126
|
+
return sql, params
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""SQLite 方言(dev/测试路径):ON CONFLICT 优先,无唯一约束时 DELETE+INSERT 兜底。"""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import text
|
|
4
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlmodel import SQLModel
|
|
7
|
+
|
|
8
|
+
from streamgate.protocols import JsonObject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _constraint_covers_keys(model: type[SQLModel], keys: list[str]) -> bool:
|
|
12
|
+
"""表上是否存在恰好覆盖 upsert 键的主键/唯一约束(ON CONFLICT 前提)。"""
|
|
13
|
+
table = model.__table__ # type: ignore[reportAttributeAccessIssue] # SQLModel stub lacks SQLAlchemy Table
|
|
14
|
+
key_set = set(keys)
|
|
15
|
+
for constraint in (*table.constraints, *table.indexes):
|
|
16
|
+
cols = {c.name for c in getattr(constraint, "columns", [])}
|
|
17
|
+
if cols and cols == key_set and getattr(constraint, "unique", False):
|
|
18
|
+
return True
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def upsert_on_conflict(
|
|
23
|
+
session: AsyncSession,
|
|
24
|
+
model: type[SQLModel],
|
|
25
|
+
rows: list[JsonObject],
|
|
26
|
+
keys: list[str],
|
|
27
|
+
fields: list[str],
|
|
28
|
+
) -> None:
|
|
29
|
+
"""INSERT ... ON CONFLICT(keys) DO UPDATE(要求键上有唯一约束/主键)。"""
|
|
30
|
+
stmt = sqlite_insert(model).values(rows)
|
|
31
|
+
update_cols = {
|
|
32
|
+
k: stmt.excluded[k] for k in fields if k not in keys
|
|
33
|
+
}
|
|
34
|
+
stmt = stmt.on_conflict_do_update(index_elements=keys, set_=update_cols)
|
|
35
|
+
await session.execute(stmt)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def delete_insert_by_keys(
|
|
39
|
+
session: AsyncSession,
|
|
40
|
+
model: type[SQLModel],
|
|
41
|
+
rows: list[JsonObject],
|
|
42
|
+
keys: list[str],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""无唯一约束时的幂等兜底:事务内按键 DELETE 后 INSERT(仅测试环境用)。"""
|
|
45
|
+
pairs = [tuple(d.get(k) for k in keys) for d in rows]
|
|
46
|
+
pairs = [p for p in pairs if all(v is not None for v in p)]
|
|
47
|
+
table_name = model.__tablename__
|
|
48
|
+
where = " AND ".join(f"{k} = :k{i}" for i, k in enumerate(keys))
|
|
49
|
+
stmt = text(f"DELETE FROM {table_name} WHERE {where}")
|
|
50
|
+
for pair in pairs:
|
|
51
|
+
params = {f"k{i}": v for i, v in enumerate(pair)}
|
|
52
|
+
await session.execute(stmt, params)
|
|
53
|
+
await session.execute(model.__table__.insert(), rows) # type: ignore[reportAttributeAccessIssue] # SQLModel stub lacks SQLAlchemy Table
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def supports_on_conflict(model: type[SQLModel], keys: list[str]) -> bool:
|
|
57
|
+
return _constraint_covers_keys(model, keys)
|
streamgate/db/engines.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""引擎工厂(读写池、超时钩子,唯一建引擎点)。"""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import event
|
|
4
|
+
from sqlalchemy.ext.asyncio import (
|
|
5
|
+
AsyncEngine,
|
|
6
|
+
AsyncSession,
|
|
7
|
+
async_sessionmaker,
|
|
8
|
+
create_async_engine,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from streamgate.config import DbConfig
|
|
12
|
+
from streamgate.obs.logging import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_query_timeout_hook(timeout_seconds: int):
|
|
16
|
+
"""SQLAlchemy connect 事件钩子:建连后设置 pyodbc 语句级查询超时(秒)。
|
|
17
|
+
|
|
18
|
+
生产驱动(pyodbc 系 / aioodbc)通过 dbapi_conn.timeout 控制,
|
|
19
|
+
超时后连接真正释放;SQLite 无此概念(dev 不适用)。
|
|
20
|
+
aioodbc 异步引擎的 connect 事件拿到的是 AsyncAdapt 包装连接,
|
|
21
|
+
timeout 需落在底层 driver_connection(aioodbc/pyodbc 真实连接)上,
|
|
22
|
+
故优先取 driver_connection 再 setattr。
|
|
23
|
+
aioodbc 0.5.x 的 Connection.timeout 是只读 property(无 setter),
|
|
24
|
+
setattr 会抛 AttributeError;真实 pyodbc 连接存于 Connection._conn,
|
|
25
|
+
SQLAlchemy 的 AsyncAdapt autocommit setter 亦穿透 _conn 设置(模式一致),
|
|
26
|
+
故失败时穿透 _conn 再设一次。
|
|
27
|
+
驱动差异防御:属性不存在/设置失败仅 WARN,不阻断建连;
|
|
28
|
+
调用层 wait_for 仍是硬保障。
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def _on_connect(dbapi_conn: object, _record: object) -> None:
|
|
32
|
+
try:
|
|
33
|
+
driver = getattr(dbapi_conn, "driver_connection", dbapi_conn)
|
|
34
|
+
_set_driver_timeout(driver, timeout_seconds)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.warning("db_query_timeout_set_failed", error=str(e))
|
|
37
|
+
|
|
38
|
+
return _on_connect
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _set_driver_timeout(conn: object, timeout_seconds: int) -> None:
|
|
42
|
+
try:
|
|
43
|
+
setattr(conn, "timeout", timeout_seconds)
|
|
44
|
+
except AttributeError:
|
|
45
|
+
underlying = getattr(conn, "_conn", None)
|
|
46
|
+
if underlying is None:
|
|
47
|
+
raise
|
|
48
|
+
setattr(underlying, "timeout", timeout_seconds)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_sqlite(config: DbConfig) -> bool:
|
|
52
|
+
return "sqlite" in config.require_connection_string().lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _create_engine(config: DbConfig, pool_size: int, max_overflow: int, timeout_seconds: int) -> AsyncEngine:
|
|
56
|
+
"""引擎创建单点:内置 sqlite/mssql 分支、池参数、timeout 钩子挂载。
|
|
57
|
+
|
|
58
|
+
C7 装配校验:connection_string 缺失即在此抛错(含修复指引),
|
|
59
|
+
对齐 KAFKA__TOPIC / CONSUMER__GROUP_ID 的"必填即启动失败"语义。
|
|
60
|
+
"""
|
|
61
|
+
connection_string = config.require_connection_string()
|
|
62
|
+
if _is_sqlite(config):
|
|
63
|
+
return create_async_engine(
|
|
64
|
+
connection_string,
|
|
65
|
+
echo=config.echo,
|
|
66
|
+
connect_args={"check_same_thread": False},
|
|
67
|
+
)
|
|
68
|
+
engine = create_async_engine(
|
|
69
|
+
connection_string,
|
|
70
|
+
echo=config.echo,
|
|
71
|
+
pool_size=pool_size,
|
|
72
|
+
max_overflow=max_overflow,
|
|
73
|
+
pool_timeout=config.pool_timeout_seconds, # 池耗尽快速失败
|
|
74
|
+
pool_recycle=3600,
|
|
75
|
+
pool_pre_ping=True,
|
|
76
|
+
)
|
|
77
|
+
# 建连即设驱动语句超时(pyodbc 系 dbapi_conn.timeout),
|
|
78
|
+
# 钩子内含驱动差异防御,失败仅 WARN 不阻断建连。
|
|
79
|
+
if timeout_seconds > 0:
|
|
80
|
+
event.listen(
|
|
81
|
+
engine.sync_engine,
|
|
82
|
+
"connect",
|
|
83
|
+
make_query_timeout_hook(timeout_seconds),
|
|
84
|
+
)
|
|
85
|
+
return engine
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_write_engine(config: DbConfig) -> AsyncEngine:
|
|
89
|
+
"""consumer 写库引擎(池参数见 DB__WRITE_POOL_* 配置键)。"""
|
|
90
|
+
return _create_engine(
|
|
91
|
+
config,
|
|
92
|
+
pool_size=config.write_pool_size,
|
|
93
|
+
max_overflow=config.write_pool_max_overflow,
|
|
94
|
+
timeout_seconds=config.write_timeout_seconds,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_read_engine(config: DbConfig) -> AsyncEngine:
|
|
99
|
+
"""ingest 读库引擎(冷实体回源专用)。"""
|
|
100
|
+
return _create_engine(
|
|
101
|
+
config,
|
|
102
|
+
pool_size=config.read_pool_size,
|
|
103
|
+
max_overflow=config.read_pool_max_overflow,
|
|
104
|
+
timeout_seconds=config.query_timeout_seconds,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
109
|
+
"""统一 session factory 参数(expire_on_commit=False)。"""
|
|
110
|
+
return async_sessionmaker(
|
|
111
|
+
engine,
|
|
112
|
+
expire_on_commit=False,
|
|
113
|
+
)
|