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.
@@ -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)
@@ -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
+ )