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,649 @@
|
|
|
1
|
+
"""消费循环:批量缓冲(size/timeout 双触发)、毒丸跳过并提交、
|
|
2
|
+
paused 指数退避自愈、失败分类 → DLQ 二分隔离、停机 flush。"""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from aiokafka.errors import CommitFailedError
|
|
10
|
+
from aiokafka.structs import OffsetAndMetadata, TopicPartition
|
|
11
|
+
|
|
12
|
+
from streamgate.cache.existence import RedisExistenceCache
|
|
13
|
+
from streamgate.config import ConsumerConfig
|
|
14
|
+
from streamgate.consumer.classifier import (
|
|
15
|
+
FailureCategory,
|
|
16
|
+
classify_write_failure,
|
|
17
|
+
)
|
|
18
|
+
from streamgate.consumer.dlq import (
|
|
19
|
+
BisectOutcome,
|
|
20
|
+
BufferedMessage,
|
|
21
|
+
DlqProducer,
|
|
22
|
+
DlqSendError,
|
|
23
|
+
QuarantineRequest,
|
|
24
|
+
_original_message,
|
|
25
|
+
locate_and_write,
|
|
26
|
+
)
|
|
27
|
+
from streamgate.obs.logging import logger
|
|
28
|
+
from streamgate.obs.metrics import DEFAULT_METRICS, MetricsSink
|
|
29
|
+
from streamgate.protocols import (
|
|
30
|
+
AdmissionPolicy,
|
|
31
|
+
ConsumeContext,
|
|
32
|
+
Envelope,
|
|
33
|
+
JsonObject,
|
|
34
|
+
MessageCodec,
|
|
35
|
+
RecordWriter,
|
|
36
|
+
)
|
|
37
|
+
from streamgate.specs import ConsumeSpec
|
|
38
|
+
from streamgate.transport.kafka import KafkaConsumerService, KafkaRecord
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _backlog_level(age_seconds: float, existence_ttl_seconds: int) -> str:
|
|
42
|
+
"""积压时长告警阈值:WARN > TTL/2(窗口打开前最后干预机会);ERROR > TTL*0.8。"""
|
|
43
|
+
if age_seconds > existence_ttl_seconds * 0.8:
|
|
44
|
+
return "error"
|
|
45
|
+
if age_seconds > existence_ttl_seconds / 2:
|
|
46
|
+
return "warning"
|
|
47
|
+
return "ok"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConsumeRuntime:
|
|
51
|
+
"""消费服务运行时状态(非线程安全,单 event loop 内安全)。"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
spec: ConsumeSpec,
|
|
57
|
+
consumer_config: ConsumerConfig,
|
|
58
|
+
writer: RecordWriter | None,
|
|
59
|
+
consumer: KafkaConsumerService | None,
|
|
60
|
+
codec: MessageCodec,
|
|
61
|
+
existence_ttl_seconds: int,
|
|
62
|
+
persist_policy: AdmissionPolicy[JsonObject] | None = None,
|
|
63
|
+
cache: RedisExistenceCache | None = None,
|
|
64
|
+
dlq: DlqProducer | None = None,
|
|
65
|
+
metrics: MetricsSink | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.spec = spec
|
|
68
|
+
self.consumer_config = consumer_config
|
|
69
|
+
self.writer = writer
|
|
70
|
+
self.consumer = consumer
|
|
71
|
+
self.codec = codec
|
|
72
|
+
self.existence_ttl_seconds = existence_ttl_seconds
|
|
73
|
+
self.persist_policy = persist_policy
|
|
74
|
+
self.cache = cache
|
|
75
|
+
self.dlq = dlq
|
|
76
|
+
self.metrics = metrics or DEFAULT_METRICS
|
|
77
|
+
self.buffer: list[BufferedMessage] = []
|
|
78
|
+
self.last_write_at: datetime | None = None
|
|
79
|
+
self.last_commit_at: datetime | None = None
|
|
80
|
+
# --- 积压时长监控 ---
|
|
81
|
+
self.backlog_oldest_ts: float | None = None # 最老未落库消息(epoch 秒)
|
|
82
|
+
self.last_backlog_check_at: datetime | None = None
|
|
83
|
+
self.running: bool = True
|
|
84
|
+
self.paused: bool = False
|
|
85
|
+
self.recover_attempt: int = 0
|
|
86
|
+
self.reconnect_attempt: int = 0
|
|
87
|
+
# --- 坏数据隔离(DLQ)---
|
|
88
|
+
self.quarantined_count: int = 0 # 进程生命周期累计隔离条数
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def pending_count(self) -> int:
|
|
92
|
+
return len(self.buffer)
|
|
93
|
+
|
|
94
|
+
def context_snapshot(self) -> ConsumeContext:
|
|
95
|
+
"""Tier 2 只读快照(位点/commit 不开放)。"""
|
|
96
|
+
backlog_age = (
|
|
97
|
+
time.time() - self.backlog_oldest_ts
|
|
98
|
+
if self.backlog_oldest_ts is not None
|
|
99
|
+
else None
|
|
100
|
+
)
|
|
101
|
+
return ConsumeContext(
|
|
102
|
+
lag=self.consumer.lag if self.consumer is not None else 0,
|
|
103
|
+
pending_count=self.pending_count,
|
|
104
|
+
paused=self.paused,
|
|
105
|
+
quarantined_count=self.quarantined_count,
|
|
106
|
+
backlog_age_seconds=backlog_age,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _note_backlog_ts(runtime: ConsumeRuntime, timestamp_ms: int | None) -> None:
|
|
111
|
+
"""记录进入缓冲区的消息时间戳,保留最老值。
|
|
112
|
+
|
|
113
|
+
近似语义:缓冲区最老消息时间戳 = 最老未落库消息
|
|
114
|
+
(paused 期间不再 poll,缓冲区即积压下界;Kafka 侧未拉取消息只会更晚到达)。
|
|
115
|
+
"""
|
|
116
|
+
if timestamp_ms is None:
|
|
117
|
+
return
|
|
118
|
+
ts = timestamp_ms / 1000.0
|
|
119
|
+
if runtime.backlog_oldest_ts is None or ts < runtime.backlog_oldest_ts:
|
|
120
|
+
runtime.backlog_oldest_ts = ts
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_backlog_age(runtime: ConsumeRuntime) -> None:
|
|
124
|
+
"""周期性积压检查:指标输出 + 阈值告警。"""
|
|
125
|
+
if runtime.backlog_oldest_ts is not None:
|
|
126
|
+
age = time.time() - runtime.backlog_oldest_ts
|
|
127
|
+
ttl = runtime.existence_ttl_seconds
|
|
128
|
+
fields = {
|
|
129
|
+
"backlog_age_seconds": round(age, 1),
|
|
130
|
+
"pending_count": runtime.pending_count,
|
|
131
|
+
"existence_ttl_seconds": ttl,
|
|
132
|
+
}
|
|
133
|
+
logger.info("backlog_age_seconds", **fields)
|
|
134
|
+
level = _backlog_level(age, ttl)
|
|
135
|
+
if level == "error":
|
|
136
|
+
logger.error("backlog_age_critical", **fields)
|
|
137
|
+
elif level == "warning":
|
|
138
|
+
logger.warning("backlog_age_warn", **fields)
|
|
139
|
+
_log_db_pool_stats(runtime)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _log_db_pool_stats(runtime: ConsumeRuntime) -> None:
|
|
143
|
+
"""DB 池饱和度(checked-out / 池内总数)。异常静默(指标不打断消费)。"""
|
|
144
|
+
engine: object = getattr(runtime.writer, "_engine", None)
|
|
145
|
+
if engine is None:
|
|
146
|
+
return
|
|
147
|
+
pool = getattr(engine, "pool", None)
|
|
148
|
+
if pool is None:
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
logger.info(
|
|
152
|
+
"db_pool_stats",
|
|
153
|
+
checkedout=pool.checkedout(),
|
|
154
|
+
pool_size=pool.size(),
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.debug("db_pool_stats_failed", error=str(e))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _dedup_by_collapse_key(
|
|
161
|
+
batch: list[BufferedMessage],
|
|
162
|
+
collapse_key: Callable[[JsonObject], object],
|
|
163
|
+
) -> list[BufferedMessage]:
|
|
164
|
+
"""按 collapse_key 去重保留最后一条(与写库侧防御性去重同序)。"""
|
|
165
|
+
seen: dict[object, BufferedMessage] = {}
|
|
166
|
+
for m in batch:
|
|
167
|
+
seen[collapse_key(m.data)] = m # 后者覆盖前者
|
|
168
|
+
return list(seen.values())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _notify_persisted(runtime: ConsumeRuntime, batch: list[BufferedMessage]) -> None:
|
|
172
|
+
"""落库成功 → 权威刷新/on_persisted 钩子(best-effort,策略内部兜底异常)。"""
|
|
173
|
+
policy = runtime.persist_policy
|
|
174
|
+
if policy is None:
|
|
175
|
+
return
|
|
176
|
+
records = (
|
|
177
|
+
_dedup_by_collapse_key(batch, runtime.spec.collapse_key)
|
|
178
|
+
if runtime.spec.collapse_key is not None
|
|
179
|
+
else batch
|
|
180
|
+
)
|
|
181
|
+
for m in records:
|
|
182
|
+
try:
|
|
183
|
+
await policy.on_persisted(m.data)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.warning("on_persisted_failed", error=str(e))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _commit_quietly(runtime: ConsumeRuntime, batch_size: int) -> None:
|
|
189
|
+
"""写库后提交位点:CommitFailed 只记日志不重试写库。"""
|
|
190
|
+
consumer = runtime.consumer
|
|
191
|
+
assert consumer is not None # 装配点保证(prepare 必建 consumer)
|
|
192
|
+
try:
|
|
193
|
+
await consumer.commit()
|
|
194
|
+
except CommitFailedError as e:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"offset_commit_failed_after_write",
|
|
197
|
+
error=str(e),
|
|
198
|
+
batch_size=batch_size,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _try_write_batch(runtime: ConsumeRuntime) -> bool:
|
|
203
|
+
"""尝试写入当前缓冲区。返回 True 表示成功,False 表示失败。
|
|
204
|
+
|
|
205
|
+
双路径分发:有 sink 走幂等 upsert 编排(含 DLQ 二分);
|
|
206
|
+
无 sink(on_record 模式)处理成功即整批可提交。
|
|
207
|
+
"""
|
|
208
|
+
if not runtime.buffer:
|
|
209
|
+
return True
|
|
210
|
+
if runtime.writer is None:
|
|
211
|
+
return await _try_handler_batch(runtime)
|
|
212
|
+
return await _try_upsert_batch(runtime)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _try_handler_batch(runtime: ConsumeRuntime) -> bool:
|
|
216
|
+
"""on_record 模式:钩子正常返回=整批可提交;抛异常=重试后 paused。
|
|
217
|
+
|
|
218
|
+
DLQ 二分不适用(无写侧可探针定位),重试耗尽转既有 paused 自愈。
|
|
219
|
+
"""
|
|
220
|
+
handler = runtime.spec.on_record
|
|
221
|
+
assert handler is not None # spec 校验保证(writer 为空必为 on_record 模式)
|
|
222
|
+
config = runtime.consumer_config
|
|
223
|
+
batch: list[BufferedMessage] = list(runtime.buffer)
|
|
224
|
+
batch_size = len(batch)
|
|
225
|
+
records = [m.data for m in batch]
|
|
226
|
+
context = runtime.context_snapshot()
|
|
227
|
+
logger.info("batch_write_start", batch_size=batch_size)
|
|
228
|
+
|
|
229
|
+
for attempt in range(config.max_retries):
|
|
230
|
+
try:
|
|
231
|
+
t0 = time.monotonic()
|
|
232
|
+
await handler(records, context)
|
|
233
|
+
duration_ms = round((time.monotonic() - t0) * 1000, 1)
|
|
234
|
+
runtime.buffer.clear() # 先清 buffer(已处理成功)
|
|
235
|
+
runtime.backlog_oldest_ts = None # 积压已清(下一批重新计)
|
|
236
|
+
runtime.last_write_at = datetime.now(timezone.utc)
|
|
237
|
+
await _notify_persisted(runtime, batch)
|
|
238
|
+
await _commit_quietly(runtime, batch_size)
|
|
239
|
+
runtime.last_commit_at = datetime.now(timezone.utc)
|
|
240
|
+
logger.info(
|
|
241
|
+
"batch_write_success",
|
|
242
|
+
batch_size=batch_size,
|
|
243
|
+
duration_ms=duration_ms,
|
|
244
|
+
)
|
|
245
|
+
return True
|
|
246
|
+
except Exception as e:
|
|
247
|
+
backoff = config.retry_backoff_base * (2 ** attempt)
|
|
248
|
+
logger.error(
|
|
249
|
+
"batch_write_failed",
|
|
250
|
+
error=str(e),
|
|
251
|
+
batch_size=batch_size,
|
|
252
|
+
retry_count=attempt + 1,
|
|
253
|
+
max_retries=config.max_retries,
|
|
254
|
+
backoff_seconds=backoff,
|
|
255
|
+
)
|
|
256
|
+
if attempt < config.max_retries - 1:
|
|
257
|
+
await asyncio.sleep(backoff)
|
|
258
|
+
|
|
259
|
+
logger.error(
|
|
260
|
+
"batch_write_exhausted_retries",
|
|
261
|
+
batch_size=batch_size,
|
|
262
|
+
max_retries=config.max_retries,
|
|
263
|
+
)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _try_upsert_batch(runtime: ConsumeRuntime) -> bool:
|
|
268
|
+
"""upsert 模式:重试写库 → 耗尽后分类处置(paused 或 DLQ 二分隔离)。"""
|
|
269
|
+
writer = runtime.writer
|
|
270
|
+
assert writer is not None # 分发点保证
|
|
271
|
+
config = runtime.consumer_config
|
|
272
|
+
batch: list[BufferedMessage] = list(runtime.buffer)
|
|
273
|
+
batch_size = len(batch)
|
|
274
|
+
logger.info("batch_write_start", batch_size=batch_size)
|
|
275
|
+
|
|
276
|
+
last_error: Exception | None = None
|
|
277
|
+
for attempt in range(config.max_retries):
|
|
278
|
+
try:
|
|
279
|
+
t0 = time.monotonic()
|
|
280
|
+
result = await writer.write([m.data for m in batch])
|
|
281
|
+
duration_ms = round((time.monotonic() - t0) * 1000, 1) # 写耗时指标
|
|
282
|
+
runtime.last_write_at = datetime.now(timezone.utc)
|
|
283
|
+
runtime.buffer.clear() # 先清 buffer(数据已落库)
|
|
284
|
+
runtime.backlog_oldest_ts = None # 积压已清(下一批重新计)
|
|
285
|
+
# 权威刷新 / on_persisted(失败仅 WARN)
|
|
286
|
+
await _notify_persisted(runtime, batch)
|
|
287
|
+
await _commit_quietly(runtime, batch_size) # 再 commit,失败只记日志
|
|
288
|
+
runtime.last_commit_at = datetime.now(timezone.utc)
|
|
289
|
+
logger.info(
|
|
290
|
+
"batch_write_success",
|
|
291
|
+
batch_size=batch_size,
|
|
292
|
+
**{f"{name}_count": count for name, count in result.counts.items()},
|
|
293
|
+
duration_ms=duration_ms, # 写耗时
|
|
294
|
+
)
|
|
295
|
+
return True
|
|
296
|
+
except Exception as e:
|
|
297
|
+
last_error = e
|
|
298
|
+
backoff = config.retry_backoff_base * (2**attempt)
|
|
299
|
+
logger.error(
|
|
300
|
+
"batch_write_failed",
|
|
301
|
+
error=str(e),
|
|
302
|
+
batch_size=batch_size,
|
|
303
|
+
retry_count=attempt + 1,
|
|
304
|
+
max_retries=config.max_retries,
|
|
305
|
+
backoff_seconds=backoff,
|
|
306
|
+
)
|
|
307
|
+
if attempt < config.max_retries - 1:
|
|
308
|
+
await asyncio.sleep(backoff)
|
|
309
|
+
|
|
310
|
+
return await _handle_write_failure(runtime, writer, batch, last_error)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _handle_write_failure(
|
|
314
|
+
runtime: ConsumeRuntime,
|
|
315
|
+
writer: RecordWriter,
|
|
316
|
+
batch: list[BufferedMessage],
|
|
317
|
+
last_error: Exception | None,
|
|
318
|
+
) -> bool:
|
|
319
|
+
"""重试耗尽后的失败分类处置:paused 自愈 或 二分定位隔离。
|
|
320
|
+
|
|
321
|
+
writer 由 _try_upsert_batch 收窄后显式传入(此路径必有写侧)。
|
|
322
|
+
"""
|
|
323
|
+
config = runtime.consumer_config
|
|
324
|
+
logger.error(
|
|
325
|
+
"batch_write_exhausted_retries",
|
|
326
|
+
batch_size=len(batch),
|
|
327
|
+
max_retries=config.max_retries,
|
|
328
|
+
)
|
|
329
|
+
if last_error is None:
|
|
330
|
+
return False # 理论不可达(耗尽必经 except),防御分支
|
|
331
|
+
dlq = runtime.dlq
|
|
332
|
+
if dlq is None or not config.dlq_enabled:
|
|
333
|
+
return False # 逃生门 CONSUMER__DLQ_ENABLED=false / 未接线:paused 旧行为
|
|
334
|
+
category = classify_write_failure(last_error)
|
|
335
|
+
logger.info(
|
|
336
|
+
"write_failure_classified", category=category.value, error=str(last_error)
|
|
337
|
+
)
|
|
338
|
+
if category is FailureCategory.INFRASTRUCTURE:
|
|
339
|
+
return False # 基础设施类 paused 自愈,语义完全不变
|
|
340
|
+
# 数据类/未知类 → 二分定位(探针对照,防误隔离)
|
|
341
|
+
logger.warning(
|
|
342
|
+
"batch_bisect_triggered",
|
|
343
|
+
batch_size=len(batch),
|
|
344
|
+
category=category.value,
|
|
345
|
+
error=str(last_error),
|
|
346
|
+
)
|
|
347
|
+
try:
|
|
348
|
+
outcome = await locate_and_write(
|
|
349
|
+
writer, dlq, batch, category, str(last_error)
|
|
350
|
+
)
|
|
351
|
+
except DlqSendError:
|
|
352
|
+
# dlq_send_failed 已由 DlqProducer 记日志;批次不提交 offset、转 paused
|
|
353
|
+
# 下轮整体重试(好条幂等重写,坏条重复隔离仅 DLQ 多一条,可接受)
|
|
354
|
+
return False
|
|
355
|
+
if outcome is None:
|
|
356
|
+
return False # 探针失败 → 疑似 DB 故障 → paused(不隔离任何数据)
|
|
357
|
+
return await _finalize_bisect_outcome(runtime, batch, outcome)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _finalize_bisect_outcome(
|
|
361
|
+
runtime: ConsumeRuntime,
|
|
362
|
+
batch: list[BufferedMessage],
|
|
363
|
+
outcome: BisectOutcome,
|
|
364
|
+
) -> bool:
|
|
365
|
+
"""定位完成:好条已入库、坏条已隔离,按成功等价收尾。"""
|
|
366
|
+
runtime.buffer.clear()
|
|
367
|
+
runtime.backlog_oldest_ts = None
|
|
368
|
+
runtime.last_write_at = datetime.now(timezone.utc)
|
|
369
|
+
runtime.quarantined_count += len(outcome.quarantined)
|
|
370
|
+
for q in outcome.quarantined:
|
|
371
|
+
log_ctx = (
|
|
372
|
+
runtime.spec.log_context(q.message.data)
|
|
373
|
+
if runtime.spec.log_context is not None
|
|
374
|
+
else {}
|
|
375
|
+
)
|
|
376
|
+
logger.error(
|
|
377
|
+
"consumer_record_quarantined",
|
|
378
|
+
**log_ctx,
|
|
379
|
+
partition=q.request.partition,
|
|
380
|
+
offset=q.request.offset,
|
|
381
|
+
reason=q.request.category,
|
|
382
|
+
error=q.request.error,
|
|
383
|
+
)
|
|
384
|
+
if outcome.written:
|
|
385
|
+
# 只刷新实际入库的好条;被隔离条不动占位(
|
|
386
|
+
# 占位残留 + 后续 409 是正确终态,阻止坏数据静默循环重灌)
|
|
387
|
+
await _notify_persisted(runtime, outcome.written)
|
|
388
|
+
await _commit_quietly(runtime, len(batch))
|
|
389
|
+
runtime.last_commit_at = datetime.now(timezone.utc)
|
|
390
|
+
logger.info(
|
|
391
|
+
"batch_write_success_with_quarantine",
|
|
392
|
+
batch_size=len(batch),
|
|
393
|
+
written=len(outcome.written),
|
|
394
|
+
quarantined=len(outcome.quarantined),
|
|
395
|
+
)
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _write_timeout_due(runtime: ConsumeRuntime) -> bool:
|
|
400
|
+
"""缓冲区非空且距上次写入超过 batch_timeout_seconds。"""
|
|
401
|
+
config = runtime.consumer_config
|
|
402
|
+
if not (runtime.buffer and runtime.last_write_at):
|
|
403
|
+
return False
|
|
404
|
+
elapsed = (
|
|
405
|
+
datetime.now(timezone.utc) - runtime.last_write_at
|
|
406
|
+
).total_seconds()
|
|
407
|
+
return elapsed >= config.batch_timeout_seconds
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def _trigger_write(runtime: ConsumeRuntime) -> bool:
|
|
411
|
+
"""触发一次写入;失败转 paused(超时/条数触发路径共用)。"""
|
|
412
|
+
success = await _try_write_batch(runtime)
|
|
413
|
+
if not success:
|
|
414
|
+
runtime.paused = True
|
|
415
|
+
logger.error("consumer_paused_due_to_write_failures")
|
|
416
|
+
return success
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _paused_recovery_tick(runtime: ConsumeRuntime) -> None:
|
|
420
|
+
"""暂停状态下的恢复写入(指数退避,上限 reconnect_max_backoff_seconds)。"""
|
|
421
|
+
config = runtime.consumer_config
|
|
422
|
+
backoff = min(
|
|
423
|
+
config.reconnect_max_backoff_seconds,
|
|
424
|
+
config.reconnect_base_backoff_seconds
|
|
425
|
+
* (2 ** min(runtime.recover_attempt, 5)),
|
|
426
|
+
)
|
|
427
|
+
logger.info(
|
|
428
|
+
"consumer_paused_retry",
|
|
429
|
+
attempt=runtime.recover_attempt + 1,
|
|
430
|
+
backoff_seconds=backoff,
|
|
431
|
+
)
|
|
432
|
+
await asyncio.sleep(backoff)
|
|
433
|
+
if await _try_write_batch(runtime):
|
|
434
|
+
runtime.paused = False
|
|
435
|
+
runtime.recover_attempt = 0
|
|
436
|
+
logger.info("consumer_resumed_after_recovery")
|
|
437
|
+
else:
|
|
438
|
+
runtime.recover_attempt += 1
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def _ensure_consumer_ready(runtime: ConsumeRuntime) -> bool:
|
|
442
|
+
"""consumer 未启动时重连(指数退避)。返回 False = 本轮跳过后续步骤。"""
|
|
443
|
+
consumer = runtime.consumer
|
|
444
|
+
if consumer is None:
|
|
445
|
+
logger.error("consumer_not_initialized")
|
|
446
|
+
await asyncio.sleep(1.0)
|
|
447
|
+
return False
|
|
448
|
+
if consumer.started:
|
|
449
|
+
return True
|
|
450
|
+
config = runtime.consumer_config
|
|
451
|
+
try:
|
|
452
|
+
await consumer.start()
|
|
453
|
+
runtime.reconnect_attempt = 0
|
|
454
|
+
return True
|
|
455
|
+
except Exception as e:
|
|
456
|
+
runtime.reconnect_attempt += 1
|
|
457
|
+
backoff = min(
|
|
458
|
+
config.reconnect_max_backoff_seconds,
|
|
459
|
+
config.reconnect_base_backoff_seconds
|
|
460
|
+
* (2 ** min(runtime.reconnect_attempt - 1, 5)),
|
|
461
|
+
)
|
|
462
|
+
logger.error(
|
|
463
|
+
"kafka_reconnect_failed",
|
|
464
|
+
error=str(e),
|
|
465
|
+
attempt=runtime.reconnect_attempt,
|
|
466
|
+
backoff_seconds=backoff,
|
|
467
|
+
)
|
|
468
|
+
await asyncio.sleep(backoff)
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def _poll_records(runtime: ConsumeRuntime) -> list[KafkaRecord] | None:
|
|
473
|
+
"""拉取一批消息;poll 异常时退避 1s(返回 None = 本轮跳过)。"""
|
|
474
|
+
consumer = runtime.consumer
|
|
475
|
+
assert consumer is not None # 仅在 _ensure_consumer_ready 通过后调用
|
|
476
|
+
try:
|
|
477
|
+
return await consumer.poll()
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.error("kafka_poll_error", error=str(e))
|
|
480
|
+
await asyncio.sleep(1.0)
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _quarantine_type_mismatch(
|
|
485
|
+
runtime: ConsumeRuntime,
|
|
486
|
+
record: KafkaRecord,
|
|
487
|
+
envelope: Envelope,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""未知 type 前向兼容——隔离至 DLQ 推进位点,不 crash 不循环。"""
|
|
490
|
+
if runtime.dlq is not None:
|
|
491
|
+
request = _build_type_mismatch_request(record, envelope)
|
|
492
|
+
try:
|
|
493
|
+
await runtime.dlq.quarantine(request)
|
|
494
|
+
runtime.quarantined_count += 1
|
|
495
|
+
return
|
|
496
|
+
except DlqSendError:
|
|
497
|
+
logger.error(
|
|
498
|
+
"unknown_type_dlq_failed",
|
|
499
|
+
partition=record.partition,
|
|
500
|
+
offset=record.offset,
|
|
501
|
+
message_type=envelope.type,
|
|
502
|
+
)
|
|
503
|
+
return
|
|
504
|
+
logger.error(
|
|
505
|
+
"unknown_type_skipped",
|
|
506
|
+
partition=record.partition,
|
|
507
|
+
offset=record.offset,
|
|
508
|
+
message_type=envelope.type,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def _process_record(
|
|
513
|
+
runtime: ConsumeRuntime,
|
|
514
|
+
record: KafkaRecord,
|
|
515
|
+
poison_offsets: dict[TopicPartition, OffsetAndMetadata],
|
|
516
|
+
) -> None:
|
|
517
|
+
"""单条消息处理:毒丸/未知 type → 推进位点;合法消息 → 入缓冲区。"""
|
|
518
|
+
value = record.value
|
|
519
|
+
envelope = runtime.codec.decode(value)
|
|
520
|
+
if envelope is None or value is None:
|
|
521
|
+
logger.error(
|
|
522
|
+
"poison_message_skipped",
|
|
523
|
+
partition=record.partition,
|
|
524
|
+
offset=record.offset,
|
|
525
|
+
raw_message=value[:500] if value else "",
|
|
526
|
+
)
|
|
527
|
+
tp = TopicPartition(record.topic, record.partition)
|
|
528
|
+
poison_offsets[tp] = OffsetAndMetadata(record.offset + 1, "")
|
|
529
|
+
return
|
|
530
|
+
if (
|
|
531
|
+
runtime.spec.expected_message_type is not None
|
|
532
|
+
and envelope.type is not None
|
|
533
|
+
and envelope.type != runtime.spec.expected_message_type
|
|
534
|
+
):
|
|
535
|
+
await _quarantine_type_mismatch(runtime, record, envelope)
|
|
536
|
+
tp = TopicPartition(record.topic, record.partition)
|
|
537
|
+
poison_offsets[tp] = OffsetAndMetadata(record.offset + 1, "")
|
|
538
|
+
return
|
|
539
|
+
# 消息源信息随数据入缓冲区:DLQ 隔离需要 partition/offset/key 与
|
|
540
|
+
# 原始消息全文(完整转发),data 本身不含这些(decode 只提取 data 字段)
|
|
541
|
+
runtime.buffer.append(
|
|
542
|
+
BufferedMessage(
|
|
543
|
+
data=envelope.data,
|
|
544
|
+
partition=record.partition,
|
|
545
|
+
offset=record.offset,
|
|
546
|
+
key=record.key,
|
|
547
|
+
raw_value=value,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
_note_backlog_ts(runtime, record.timestamp) # 追最老未落库消息
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
async def _consume_records(
|
|
554
|
+
runtime: ConsumeRuntime,
|
|
555
|
+
records: list[KafkaRecord],
|
|
556
|
+
) -> None:
|
|
557
|
+
"""整批消息处理 + 毒丸位点提交(推进消费位点,避免重启后重复处理)。"""
|
|
558
|
+
poison_offsets: dict[TopicPartition, OffsetAndMetadata] = {}
|
|
559
|
+
for record in records:
|
|
560
|
+
await _process_record(runtime, record, poison_offsets)
|
|
561
|
+
if poison_offsets:
|
|
562
|
+
consumer = runtime.consumer
|
|
563
|
+
assert consumer is not None
|
|
564
|
+
try:
|
|
565
|
+
await consumer.commit(poison_offsets)
|
|
566
|
+
logger.info("poison_offsets_committed", count=len(poison_offsets))
|
|
567
|
+
except CommitFailedError as e:
|
|
568
|
+
logger.warning("poison_offset_commit_failed", error=str(e))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
async def _periodic_backlog_check(runtime: ConsumeRuntime, config: ConsumerConfig) -> None:
|
|
572
|
+
"""周期性积压/池指标检查(含 paused 状态,DB 故障期最需要)+ lag 真实化。"""
|
|
573
|
+
now_dt = datetime.now(timezone.utc)
|
|
574
|
+
if (
|
|
575
|
+
runtime.last_backlog_check_at is not None
|
|
576
|
+
and (now_dt - runtime.last_backlog_check_at).total_seconds()
|
|
577
|
+
< config.backlog_check_interval_seconds
|
|
578
|
+
):
|
|
579
|
+
return
|
|
580
|
+
runtime.last_backlog_check_at = now_dt
|
|
581
|
+
_check_backlog_age(runtime)
|
|
582
|
+
# R4:lag 真实化(与积压检查同周期计算,健康接口只读缓存值)
|
|
583
|
+
consumer = runtime.consumer
|
|
584
|
+
if consumer is not None:
|
|
585
|
+
try:
|
|
586
|
+
await consumer.refresh_lag()
|
|
587
|
+
except Exception:
|
|
588
|
+
logger.debug("lag_refresh_skipped", exc_info=True)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def consume_loop(runtime: ConsumeRuntime) -> None:
|
|
592
|
+
"""主消费循环。"""
|
|
593
|
+
config = runtime.consumer_config
|
|
594
|
+
runtime.running = True
|
|
595
|
+
# 初始化为当前时间,避免首批消息不足 batch_size 时超时检查因 last_write_at=None 永不触发
|
|
596
|
+
runtime.last_write_at = datetime.now(timezone.utc)
|
|
597
|
+
|
|
598
|
+
logger.info(
|
|
599
|
+
"consumer_loop_started",
|
|
600
|
+
group_id=config.group_id,
|
|
601
|
+
batch_size=config.batch_size,
|
|
602
|
+
batch_timeout=config.batch_timeout_seconds,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
while runtime.running:
|
|
606
|
+
await _periodic_backlog_check(runtime, config)
|
|
607
|
+
|
|
608
|
+
if runtime.paused:
|
|
609
|
+
# 暂停状态下尝试恢复写库(指数退避,上限 reconnect_max_backoff_seconds)
|
|
610
|
+
await _paused_recovery_tick(runtime)
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
# 若 consumer 未启动,尝试重连(指数退避,上限 reconnect_max_backoff_seconds)
|
|
614
|
+
if not await _ensure_consumer_ready(runtime):
|
|
615
|
+
continue
|
|
616
|
+
|
|
617
|
+
# 检查是否该触发写入(超时)
|
|
618
|
+
if _write_timeout_due(runtime) and not await _trigger_write(runtime):
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
# 拉取消息
|
|
622
|
+
records = await _poll_records(runtime)
|
|
623
|
+
if records is not None:
|
|
624
|
+
await _consume_records(runtime, records)
|
|
625
|
+
|
|
626
|
+
# 检查是否该触发写入(条数)
|
|
627
|
+
if len(runtime.buffer) >= config.batch_size:
|
|
628
|
+
await _trigger_write(runtime)
|
|
629
|
+
|
|
630
|
+
# 优雅停机:写完缓冲区剩余数据
|
|
631
|
+
if runtime.buffer:
|
|
632
|
+
logger.info("shutdown_flushing_buffer", pending=len(runtime.buffer))
|
|
633
|
+
await _try_write_batch(runtime)
|
|
634
|
+
|
|
635
|
+
logger.info("consumer_loop_stopped")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _build_type_mismatch_request(
|
|
639
|
+
record: KafkaRecord, envelope: Envelope
|
|
640
|
+
) -> QuarantineRequest:
|
|
641
|
+
return QuarantineRequest(
|
|
642
|
+
partition=record.partition,
|
|
643
|
+
offset=record.offset,
|
|
644
|
+
key=record.key,
|
|
645
|
+
original_message=_original_message(record.value),
|
|
646
|
+
category="unknown_type",
|
|
647
|
+
error=f"unexpected envelope type: {envelope.type}",
|
|
648
|
+
stage="decode",
|
|
649
|
+
)
|