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,139 @@
|
|
|
1
|
+
"""写库失败分类器:消费端写库异常的单点分类模块。
|
|
2
|
+
|
|
3
|
+
分类决定处置路径:
|
|
4
|
+
- INFRASTRUCTURE:连接/超时/死锁/池耗尽 → 保留既有 paused 无限自愈
|
|
5
|
+
- DATA:字符串截断/类型转换/约束违反 → 触发二分定位隔离
|
|
6
|
+
- UNKNOWN:其余 → 同 DATA(二分+探针对照会把结果安全归入上两类)
|
|
7
|
+
|
|
8
|
+
错误号事实来源:SQL Server 官方错误文档;常量表按生产实际持续扩充
|
|
9
|
+
(错误号缺漏导致的数据类误判为基础设施类会 paused 死循环,
|
|
10
|
+
由 backlog_age_warn / backpressure_tripped 告警兜底人工介入,不丢数据)。
|
|
11
|
+
使用方可通过 add_failure_rule 追加 (pattern → category) 规则,
|
|
12
|
+
追加规则先于内置规则评估(使用方覆盖语义)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable, Iterator
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
from sqlalchemy.exc import (
|
|
21
|
+
DataError,
|
|
22
|
+
IntegrityError,
|
|
23
|
+
OperationalError,
|
|
24
|
+
)
|
|
25
|
+
from sqlalchemy.exc import (
|
|
26
|
+
TimeoutError as SQLAlchemyTimeoutError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FailureCategory(str, Enum):
|
|
31
|
+
INFRASTRUCTURE = "infrastructure"
|
|
32
|
+
DATA = "data"
|
|
33
|
+
UNKNOWN = "unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# 使用方追加规则(先于内置规则评估;返回 None 表示不适用,继续下一条)
|
|
37
|
+
ExtraRule = Callable[[Exception], "FailureCategory | None"]
|
|
38
|
+
_EXTRA_RULES: list[ExtraRule] = []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_failure_rule(rule: ExtraRule) -> None:
|
|
42
|
+
"""追加分类规则(使用方扩展点)。"""
|
|
43
|
+
_EXTRA_RULES.append(rule)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- SQL Server 原生错误号 → 分类(常量表,持续扩充)---
|
|
47
|
+
# 数据类:重试必然无意义,写库被内容本身卡死
|
|
48
|
+
_MSSQL_DATA_ERROR_NUMBERS: frozenset[int] = frozenset({
|
|
49
|
+
8152, 2628, # 字符串或二进制数据将被截断(varchar(50) 超长)
|
|
50
|
+
8114, 8169, 245, # 类型转换失败(float/datetime 非法值)
|
|
51
|
+
2627, 2714, # 唯一约束/主键冲突、重复对象
|
|
52
|
+
547, 233, 515, # 外键/必填列/不允许 NULL 插入
|
|
53
|
+
})
|
|
54
|
+
# 基础设施类:DB 侧可自愈,重试有意义
|
|
55
|
+
_MSSQL_INFRA_ERROR_NUMBERS: frozenset[int] = frozenset({
|
|
56
|
+
1205, # 死锁 victim
|
|
57
|
+
1204, # 锁资源不足
|
|
58
|
+
1222, # 锁请求超时
|
|
59
|
+
-2, # 查询超时(Timeout expired)
|
|
60
|
+
4060, 18456, # 登录失败/认证失败
|
|
61
|
+
40197, 40613, 40501, 49918, 49919, 49920, # 节流/资源暂不可用
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# OperationalError 文本兜底关键字(小写化匹配;连接类错误的常见措辞)
|
|
65
|
+
_INFRA_KEYWORDS: tuple[str, ...] = (
|
|
66
|
+
"connection", "timeout", "broken pipe", "reset by peer",
|
|
67
|
+
"closed", "login failed", "pool",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# pyodbc 错误消息格式:"('23000', '[23000] ... (2627) (SQLExecDirectW)')"
|
|
71
|
+
# 错误号在末尾圆括号中;SQLSTATE('23000' / [23000])带引号或方括号,不会被误匹配
|
|
72
|
+
_ERROR_NUMBER_RE = re.compile(r"\((-?\d{1,10})\)")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _iter_exception_chain(exc: BaseException) -> Iterator[BaseException]:
|
|
76
|
+
"""遍历异常链:当前 → __cause__(显式)→ .orig(SQLAlchemy DBAPIError 包装的驱动原始异常)。
|
|
77
|
+
|
|
78
|
+
刻意不走 __context__(隐式链):except 块中可能夹带无关的历史异常,
|
|
79
|
+
把它的错误号算进来会误分类。
|
|
80
|
+
"""
|
|
81
|
+
seen: set[int] = set()
|
|
82
|
+
current: BaseException | None = exc
|
|
83
|
+
while current is not None and id(current) not in seen:
|
|
84
|
+
seen.add(id(current))
|
|
85
|
+
yield current
|
|
86
|
+
cause = getattr(current, "__cause__", None)
|
|
87
|
+
orig = getattr(current, "orig", None)
|
|
88
|
+
nxt = cause if cause is not None else orig
|
|
89
|
+
current = nxt if isinstance(nxt, BaseException) else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_mssql_error_numbers(exc: BaseException) -> set[int]:
|
|
93
|
+
"""从异常链各节点字符串中提取 SQL Server 原生错误号(可能多个)。"""
|
|
94
|
+
numbers: set[int] = set()
|
|
95
|
+
for node in _iter_exception_chain(exc):
|
|
96
|
+
for match in _ERROR_NUMBER_RE.finditer(str(node)):
|
|
97
|
+
numbers.add(int(match.group(1)))
|
|
98
|
+
return numbers
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def classify_write_failure(exc: Exception) -> FailureCategory:
|
|
102
|
+
"""写库失败分类(单点)。优先级见 docstring;分类错误的后果是安全方向的:
|
|
103
|
+
误判 infra→data 会走二分,探针失败自然回落 paused;误判 data→infra 会
|
|
104
|
+
paused 死循环,由积压告警兜底。
|
|
105
|
+
"""
|
|
106
|
+
# 0. 使用方追加规则(最具体,优先评估)
|
|
107
|
+
for rule in _EXTRA_RULES:
|
|
108
|
+
result = rule(exc)
|
|
109
|
+
if result is not None:
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
# 1. 事务/池超时:writer.write 的 wait_for 兜底会再抛 asyncio.TimeoutError
|
|
113
|
+
if isinstance(exc, asyncio.TimeoutError):
|
|
114
|
+
return FailureCategory.INFRASTRUCTURE
|
|
115
|
+
# SQLAlchemy 连接池耗尽(sqlalchemy.exc.TimeoutError,与 asyncio 的同名不同类)
|
|
116
|
+
if isinstance(exc, SQLAlchemyTimeoutError):
|
|
117
|
+
return FailureCategory.INFRASTRUCTURE
|
|
118
|
+
|
|
119
|
+
# 2. 消费端自身的内容防御:键缺失 / naive 时间戳抛 ValueError,
|
|
120
|
+
# 本质是"消息内容入不了库"(旧消息残留场景),属数据类
|
|
121
|
+
if isinstance(exc, ValueError):
|
|
122
|
+
return FailureCategory.DATA
|
|
123
|
+
|
|
124
|
+
# 3. SQL Server 错误号表(数据表优先判:更具体)
|
|
125
|
+
numbers = _extract_mssql_error_numbers(exc)
|
|
126
|
+
if numbers & _MSSQL_DATA_ERROR_NUMBERS:
|
|
127
|
+
return FailureCategory.DATA
|
|
128
|
+
if numbers & _MSSQL_INFRA_ERROR_NUMBERS:
|
|
129
|
+
return FailureCategory.INFRASTRUCTURE
|
|
130
|
+
|
|
131
|
+
# 4. SQLAlchemy 异常族兜底(SQLite 开发路径没有 MSSQL 错误号,走这里)
|
|
132
|
+
if isinstance(exc, (IntegrityError, DataError)):
|
|
133
|
+
return FailureCategory.DATA
|
|
134
|
+
if isinstance(exc, OperationalError):
|
|
135
|
+
text = str(exc).lower()
|
|
136
|
+
if any(keyword in text for keyword in _INFRA_KEYWORDS):
|
|
137
|
+
return FailureCategory.INFRASTRUCTURE
|
|
138
|
+
return FailureCategory.UNKNOWN
|
|
139
|
+
return FailureCategory.UNKNOWN
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Kafka DLQ(死信队列)producer + bisect 二分定位。
|
|
2
|
+
|
|
3
|
+
消费端隔离单条坏数据的出口:把二分定位判定的坏消息连同失败原因
|
|
4
|
+
完整转发到独立死信 topic,供人工留档/处置(不建自动消费/重放)。
|
|
5
|
+
|
|
6
|
+
与 ingest 侧 KafkaProducerService 的差异(刻意从简,勿"补齐"):
|
|
7
|
+
- 无自愈监控任务:发送重试耗尽即抛 DlqSendError,由消费循环既有 paused
|
|
8
|
+
机制兜底(隔离动作必须成功才允许推进 offset,杜绝静默丢失);
|
|
9
|
+
- 单飞行假设:仅被消费循环(单 event loop 顺序调用)使用,无并发锁;
|
|
10
|
+
- 失败即销毁实例重建:aiokafka 内部 fatal 状态无法自愈(对齐 ingest producer 经验)。
|
|
11
|
+
|
|
12
|
+
注意:禁止 import streamgate.ingest.*(进程隔离契约,lint-imports 门禁)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from aiokafka import AIOKafkaProducer
|
|
21
|
+
|
|
22
|
+
from streamgate.config import KafkaConfig
|
|
23
|
+
from streamgate.consumer.classifier import FailureCategory
|
|
24
|
+
from streamgate.obs.logging import logger
|
|
25
|
+
from streamgate.protocols import JsonObject, RecordWriter
|
|
26
|
+
|
|
27
|
+
DLQ_SEND_RETRY_INTERVAL_SECONDS = 1.0 # 发送尝试间隔(隔离路径不在吞吐热区,固定值即可)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QuarantineRequest:
|
|
32
|
+
"""单条隔离请求:来源消息定位 + 原始消息 + 失败原因。"""
|
|
33
|
+
|
|
34
|
+
partition: int
|
|
35
|
+
offset: int
|
|
36
|
+
key: str | None
|
|
37
|
+
original_message: JsonObject
|
|
38
|
+
category: str
|
|
39
|
+
error: str
|
|
40
|
+
stage: str = "bisect"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DlqSendError(RuntimeError):
|
|
44
|
+
"""DLQ 发送重试耗尽。调用方必须:不提交 offset、整批转 paused。"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class BufferedMessage:
|
|
49
|
+
"""缓冲区条目:data 为解析后的载荷 dict(写库输入),其余字段保留
|
|
50
|
+
Kafka 消息源信息(DLQ 隔离时定位原消息、完整转发用)。"""
|
|
51
|
+
|
|
52
|
+
data: JsonObject
|
|
53
|
+
partition: int
|
|
54
|
+
offset: int
|
|
55
|
+
key: str | None
|
|
56
|
+
raw_value: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_dlq_payload(
|
|
60
|
+
request: QuarantineRequest, message_type: str = "streamgate_dlq"
|
|
61
|
+
) -> JsonObject:
|
|
62
|
+
"""构造 DLQ 消息体(纯函数,便于离线校验结构;键名是 DLQ 消费方契约,勿改)。"""
|
|
63
|
+
return {
|
|
64
|
+
"type": message_type,
|
|
65
|
+
"quarantined_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
66
|
+
"reason": {
|
|
67
|
+
"category": request.category,
|
|
68
|
+
"error": request.error,
|
|
69
|
+
"stage": request.stage,
|
|
70
|
+
},
|
|
71
|
+
"source": {
|
|
72
|
+
"partition": request.partition,
|
|
73
|
+
"offset": request.offset,
|
|
74
|
+
"key": request.key,
|
|
75
|
+
},
|
|
76
|
+
"original_message": request.original_message,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DlqProducer:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
kafka_config: KafkaConfig,
|
|
84
|
+
*,
|
|
85
|
+
topic: str | None = None,
|
|
86
|
+
send_retries: int = 3,
|
|
87
|
+
message_type: str = "streamgate_dlq",
|
|
88
|
+
) -> None:
|
|
89
|
+
self._kafka_config = kafka_config
|
|
90
|
+
self._topic = topic or kafka_config.dlq_topic
|
|
91
|
+
if not self._topic:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"dlq topic is required: set KafkaConfig.dlq_topic or pass topic explicitly"
|
|
94
|
+
)
|
|
95
|
+
self._retries = send_retries
|
|
96
|
+
self._message_type = message_type
|
|
97
|
+
self._producer: AIOKafkaProducer | None = None
|
|
98
|
+
self._started: bool = False
|
|
99
|
+
self._closed: bool = False
|
|
100
|
+
|
|
101
|
+
async def start(self) -> None:
|
|
102
|
+
"""启动(幂等)。失败仅记 ERROR 日志不抛:quarantine 内懒启动兜底,
|
|
103
|
+
消费循环照常起跑(对齐 consumer 启动期 Kafka 失败降级先例)。"""
|
|
104
|
+
if self._closed or self._started:
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
await self._connect()
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error("dlq_producer_startup_failed", error=str(e), topic=self._topic)
|
|
110
|
+
|
|
111
|
+
async def _connect(self) -> None:
|
|
112
|
+
producer = AIOKafkaProducer(
|
|
113
|
+
bootstrap_servers=self._kafka_config.bootstrap_servers,
|
|
114
|
+
acks="all",
|
|
115
|
+
request_timeout_ms=self._kafka_config.request_timeout_ms,
|
|
116
|
+
enable_idempotence=True,
|
|
117
|
+
key_serializer=lambda k: k.encode("utf-8") if isinstance(k, str) else k,
|
|
118
|
+
value_serializer=lambda v: v.encode("utf-8") if isinstance(v, str) else v,
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
await producer.start()
|
|
122
|
+
except Exception:
|
|
123
|
+
try:
|
|
124
|
+
await producer.stop()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass # 清理失败无碍:实例已弃用,重建时换新对象
|
|
127
|
+
raise
|
|
128
|
+
self._producer = producer
|
|
129
|
+
self._started = True
|
|
130
|
+
logger.info("dlq_producer_connected", topic=self._topic)
|
|
131
|
+
|
|
132
|
+
async def _close_safely(self) -> None:
|
|
133
|
+
if self._producer is None:
|
|
134
|
+
self._started = False
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
await self._producer.stop()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.debug("dlq_producer_cleanup_error", error=str(e))
|
|
140
|
+
finally:
|
|
141
|
+
self._producer = None
|
|
142
|
+
self._started = False
|
|
143
|
+
|
|
144
|
+
async def stop(self) -> None:
|
|
145
|
+
self._closed = True
|
|
146
|
+
await self._close_safely()
|
|
147
|
+
logger.info("dlq_producer_disconnected")
|
|
148
|
+
|
|
149
|
+
async def quarantine(self, request: QuarantineRequest) -> None:
|
|
150
|
+
"""隔离单条消息至 DLQ。成功静默返回;重试耗尽抛 DlqSendError。
|
|
151
|
+
|
|
152
|
+
不变量:本方法返回 ⟺ 消息已确认写入 DLQ(acks=all)——
|
|
153
|
+
调用方(消费循环)以此决定是否推进 offset。
|
|
154
|
+
"""
|
|
155
|
+
value = json.dumps(
|
|
156
|
+
build_dlq_payload(request, self._message_type),
|
|
157
|
+
ensure_ascii=False,
|
|
158
|
+
default=str,
|
|
159
|
+
)
|
|
160
|
+
last_error: Exception | None = None
|
|
161
|
+
for attempt in range(1, self._retries + 1):
|
|
162
|
+
try:
|
|
163
|
+
await self._send_once(request, value)
|
|
164
|
+
return
|
|
165
|
+
except Exception as e:
|
|
166
|
+
last_error = e
|
|
167
|
+
logger.warning(
|
|
168
|
+
"dlq_send_attempt_failed",
|
|
169
|
+
attempt=attempt,
|
|
170
|
+
retries=self._retries,
|
|
171
|
+
partition=request.partition,
|
|
172
|
+
offset=request.offset,
|
|
173
|
+
error=str(e),
|
|
174
|
+
)
|
|
175
|
+
# 失败即销毁实例:aiokafka producer 进入 fatal 状态后无法自愈
|
|
176
|
+
await self._close_safely()
|
|
177
|
+
if attempt < self._retries:
|
|
178
|
+
await asyncio.sleep(DLQ_SEND_RETRY_INTERVAL_SECONDS)
|
|
179
|
+
logger.error(
|
|
180
|
+
"dlq_send_failed",
|
|
181
|
+
retries=self._retries,
|
|
182
|
+
topic=self._topic,
|
|
183
|
+
partition=request.partition,
|
|
184
|
+
offset=request.offset,
|
|
185
|
+
error=str(last_error),
|
|
186
|
+
)
|
|
187
|
+
raise DlqSendError(
|
|
188
|
+
f"DLQ send failed after {self._retries} attempts "
|
|
189
|
+
f"(partition={request.partition}, offset={request.offset}): {last_error}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def _send_once(self, request: QuarantineRequest, value: str) -> None:
|
|
193
|
+
"""单次发送(懒启动:覆盖启动期 broker 不可用)。"""
|
|
194
|
+
if not self._started:
|
|
195
|
+
await self._connect()
|
|
196
|
+
producer = self._producer
|
|
197
|
+
if producer is None:
|
|
198
|
+
raise RuntimeError("DLQ producer not connected")
|
|
199
|
+
await producer.send_and_wait(
|
|
200
|
+
topic=self._topic,
|
|
201
|
+
key=request.key,
|
|
202
|
+
value=value,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---- 二分定位与探针对照——防误隔离的核心 ----
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class QuarantinedRecord:
|
|
211
|
+
"""被隔离的消息与其隔离请求(供调用方记日志/计数)。"""
|
|
212
|
+
|
|
213
|
+
message: BufferedMessage
|
|
214
|
+
request: QuarantineRequest
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class BisectOutcome:
|
|
219
|
+
"""定位结果。不变式:完成时 written + quarantined 恰好覆盖传入 batch 的全部条目。"""
|
|
220
|
+
|
|
221
|
+
written: list[BufferedMessage] = field(default_factory=list)
|
|
222
|
+
quarantined: list[QuarantinedRecord] = field(default_factory=list)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _BisectAborted(Exception):
|
|
226
|
+
"""探针失败(或无探针可用):疑似 DB 故障,中止整个定位过程。"""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _original_message(raw_value: str | None) -> JsonObject:
|
|
230
|
+
"""还原原始 Kafka 消息(完整 envelope:type/received_at/source/data)。
|
|
231
|
+
|
|
232
|
+
进缓冲区的消息必然通过过 decode(JSON 合法),此处防御性兜底:
|
|
233
|
+
万一解析失败,原样内嵌,DLQ 留档不丢内容。
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
if raw_value is None:
|
|
237
|
+
raise TypeError # 与历史行为一致:None 非法 JSON,原样内嵌
|
|
238
|
+
parsed = json.loads(raw_value)
|
|
239
|
+
if isinstance(parsed, dict):
|
|
240
|
+
return parsed
|
|
241
|
+
except (json.JSONDecodeError, TypeError):
|
|
242
|
+
pass
|
|
243
|
+
return {"raw": raw_value}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _build_quarantine_request(
|
|
247
|
+
message: BufferedMessage,
|
|
248
|
+
category: FailureCategory,
|
|
249
|
+
error: str,
|
|
250
|
+
) -> QuarantineRequest:
|
|
251
|
+
return QuarantineRequest(
|
|
252
|
+
partition=message.partition,
|
|
253
|
+
offset=message.offset,
|
|
254
|
+
key=message.key,
|
|
255
|
+
original_message=_original_message(message.raw_value),
|
|
256
|
+
category=category.value,
|
|
257
|
+
error=error,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _pick_probe(
|
|
262
|
+
probe_pool: list[BufferedMessage],
|
|
263
|
+
bad: BufferedMessage,
|
|
264
|
+
written: list[BufferedMessage],
|
|
265
|
+
) -> BufferedMessage | None:
|
|
266
|
+
"""探针选择:优先取本轮定位中已成功写入的记录(已证明 DB 此刻可写),
|
|
267
|
+
否则取原批内任意另一条;原批只有 1 条(无对照)→ None(由调用方决定:
|
|
268
|
+
DATA 直接隔离,UNKNOWN 按 DB 故障处理)。"""
|
|
269
|
+
for m in written:
|
|
270
|
+
if m is not bad:
|
|
271
|
+
return m
|
|
272
|
+
for m in probe_pool:
|
|
273
|
+
if m is not bad:
|
|
274
|
+
return m
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _isolate_single(
|
|
279
|
+
writer: RecordWriter,
|
|
280
|
+
dlq: DlqProducer,
|
|
281
|
+
bad: BufferedMessage,
|
|
282
|
+
probe_pool: list[BufferedMessage],
|
|
283
|
+
category: FailureCategory,
|
|
284
|
+
outcome: BisectOutcome,
|
|
285
|
+
write_error: str,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""单条失败:探针对照后隔离(探针失败 ⟹ 疑似 DB 故障,中止全局)。
|
|
288
|
+
|
|
289
|
+
原批只有 1 条、无同批对照:若分类已明确是数据问题(DATA,
|
|
290
|
+
classify_write_failure 已排除连接/超时/死锁/池耗尽等基础设施类),
|
|
291
|
+
可直接隔离——否则单条坏数据将陷入 paused→重试→paused 死循环。
|
|
292
|
+
UNKNOWN 无法断定 DB 健康,仍按不变式 2 转 paused(探针保护)。
|
|
293
|
+
"""
|
|
294
|
+
probe = _pick_probe(probe_pool, bad, outcome.written)
|
|
295
|
+
if probe is None:
|
|
296
|
+
if category is FailureCategory.DATA:
|
|
297
|
+
request = _build_quarantine_request(bad, category, write_error)
|
|
298
|
+
await dlq.quarantine(request)
|
|
299
|
+
outcome.quarantined.append(QuarantinedRecord(message=bad, request=request))
|
|
300
|
+
return
|
|
301
|
+
raise _BisectAborted(
|
|
302
|
+
f"no probe available (batch size 1) for offset={bad.offset}"
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
await writer.write([probe.data])
|
|
306
|
+
except Exception as e:
|
|
307
|
+
raise _BisectAborted(
|
|
308
|
+
f"probe write failed, suspected DB failure: {e}"
|
|
309
|
+
) from e
|
|
310
|
+
# 探针成功 ⟹ DB 可写 ⟹ 失败原因是该条数据自身 → 隔离
|
|
311
|
+
# (隔离失败抛 DlqSendError,向上穿透中止本轮,绝不跳过)
|
|
312
|
+
request = _build_quarantine_request(bad, category, write_error)
|
|
313
|
+
await dlq.quarantine(request)
|
|
314
|
+
outcome.quarantined.append(QuarantinedRecord(message=bad, request=request))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _locate(
|
|
318
|
+
writer: RecordWriter,
|
|
319
|
+
dlq: DlqProducer,
|
|
320
|
+
records: list[BufferedMessage],
|
|
321
|
+
probe_pool: list[BufferedMessage],
|
|
322
|
+
category: FailureCategory,
|
|
323
|
+
outcome: BisectOutcome,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""递归定位写入一段记录。
|
|
326
|
+
|
|
327
|
+
成功:写入并记入 outcome.written;
|
|
328
|
+
失败:二分前半/后半;单条失败用探针对照后隔离。
|
|
329
|
+
抛 _BisectAborted(疑似 DB 故障,中止全局)或 DlqSendError(DLQ 不可用)。
|
|
330
|
+
|
|
331
|
+
定位写入刻意"1 次尝试不重试":瞬时抖动由探针
|
|
332
|
+
对照兜底区分,不做退避重试。
|
|
333
|
+
"""
|
|
334
|
+
write_error = ""
|
|
335
|
+
try:
|
|
336
|
+
await writer.write([m.data for m in records])
|
|
337
|
+
outcome.written.extend(records)
|
|
338
|
+
return
|
|
339
|
+
except Exception as e:
|
|
340
|
+
write_error = str(e)
|
|
341
|
+
|
|
342
|
+
if len(records) == 1:
|
|
343
|
+
await _isolate_single(
|
|
344
|
+
writer, dlq, records[0], probe_pool, category, outcome, write_error
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
mid = len(records) // 2
|
|
349
|
+
await _locate(writer, dlq, records[:mid], probe_pool, category, outcome)
|
|
350
|
+
await _locate(writer, dlq, records[mid:], probe_pool, category, outcome)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def locate_and_write(
|
|
354
|
+
writer: RecordWriter,
|
|
355
|
+
dlq: DlqProducer,
|
|
356
|
+
batch: list[BufferedMessage],
|
|
357
|
+
category: FailureCategory,
|
|
358
|
+
error: str,
|
|
359
|
+
) -> BisectOutcome | None:
|
|
360
|
+
"""二分定位入口。返回值/异常语义见接口契约;error 为触发定位的原始批级错误,
|
|
361
|
+
用于运维上下文(真正写进 DLQ 的是各单条自身的新写错误)。"""
|
|
362
|
+
outcome = BisectOutcome()
|
|
363
|
+
try:
|
|
364
|
+
await _locate(writer, dlq, batch, batch, category, outcome)
|
|
365
|
+
except _BisectAborted as e:
|
|
366
|
+
# 探针失败:可能已有部分子批写入 DB(幂等,paused 重试时无害重写),
|
|
367
|
+
# 但本轮绝不隔离任何数据、不推进位点
|
|
368
|
+
logger.error(
|
|
369
|
+
"bisect_aborted_probe_failed",
|
|
370
|
+
batch_size=len(batch),
|
|
371
|
+
written=len(outcome.written),
|
|
372
|
+
error=str(e),
|
|
373
|
+
)
|
|
374
|
+
return None
|
|
375
|
+
return outcome
|