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,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
+ )