streamgate 1.0.0__tar.gz → 2.0.0__tar.gz
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-1.0.0 → streamgate-2.0.0}/.github/workflows/ci.yml +9 -4
- {streamgate-1.0.0 → streamgate-2.0.0}/CHANGELOG.md +57 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/CONFIGURATION.md +6 -2
- {streamgate-1.0.0 → streamgate-2.0.0}/PKG-INFO +2 -2
- {streamgate-1.0.0 → streamgate-2.0.0}/README.md +1 -1
- {streamgate-1.0.0 → streamgate-2.0.0}/README.zh-CN.md +61 -3
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/produce.py +1 -1
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/__init__.py +1 -1
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/__init__.py +2 -1
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/__init__.py +12 -2
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/carrier.py +14 -13
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/config.py +3 -1
- streamgate-2.0.0/src/streamgate/contrib/redis_dedup/group_cache.py +213 -0
- streamgate-2.0.0/src/streamgate/contrib/redis_dedup/group_carrier.py +472 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/backfill.py +45 -15
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/protocols.py +11 -4
- {streamgate-1.0.0 → streamgate-2.0.0}/.github/workflows/release.yml +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/.gitignore +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/AGENTS.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/CONTRIBUTING.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/LICENSE +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/docker-compose.yml +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/README.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/consume.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/health_server.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/models.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/produce.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/README.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/consume.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/health_server.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/models.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/produce.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/README.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/consume.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/models.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/README.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/models.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/produce.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/README.md +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/consume.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/models.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/produce.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/pyproject.toml +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/config.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/classifier.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/dlq.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/loop.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/options.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/runner.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/_deps.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/mssql_upsert/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/cache.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/mssql.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/sqlite.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/classifier.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/config.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/engines.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/factory.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/upsert.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sqlite_upsert/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/dedup/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/dedup/in_memory.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/producer.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/logging.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/metrics.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/backpressure.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/health.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/__init__.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/codec.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/kafka.py +0 -0
- {streamgate-1.0.0 → streamgate-2.0.0}/uv.lock +0 -0
|
@@ -100,9 +100,9 @@ jobs:
|
|
|
100
100
|
assert streamgate.__version__
|
|
101
101
|
|
|
102
102
|
from streamgate import (
|
|
103
|
-
Consumer, ConsumerOptions,
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
Consumer, ConsumerOptions, DedupOptions, DefaultErrorClassifier,
|
|
104
|
+
ErrorKind, InMemoryDedupCarrier, ManualBackpressureSignal,
|
|
105
|
+
Producer, ProducerOptions,
|
|
106
106
|
)
|
|
107
107
|
|
|
108
108
|
try:
|
|
@@ -110,6 +110,11 @@ jobs:
|
|
|
110
110
|
except TypeError as e:
|
|
111
111
|
assert "handler" in str(e)
|
|
112
112
|
|
|
113
|
+
try:
|
|
114
|
+
Producer()
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
assert "topic is required" in str(e)
|
|
117
|
+
|
|
113
118
|
print("bare-install import smoke OK")
|
|
114
119
|
EOF
|
|
115
120
|
- name: "Smoke: pure_pipeline end to end (produce → Kafka → consume → sqlite)"
|
|
@@ -120,7 +125,7 @@ jobs:
|
|
|
120
125
|
sleep 15
|
|
121
126
|
KAFKA__BOOTSTRAP_SERVERS=localhost:29092 ../../../bare/bin/python produce.py | tee produce.out
|
|
122
127
|
grep -q "o-1: accepted" produce.out
|
|
123
|
-
grep -q "o-1:
|
|
128
|
+
grep -q "o-1: duplicate" produce.out # in-process dedup contract
|
|
124
129
|
grep -q "o-2: accepted" produce.out
|
|
125
130
|
sleep 20 # allow batch-timeout flush + offset commit
|
|
126
131
|
kill -INT $CONSUMER_PID
|
|
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.0.0] - 2026-09-11
|
|
9
|
+
|
|
10
|
+
### BREAKING — unified backfill contract (feat!)
|
|
11
|
+
|
|
12
|
+
`BackfillSource.load` / `SqlBackfill.load` now speak one mapped contract:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
await load(scope: str) -> dict[str, JsonObject] | None
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
where `scope` is an identity key (single-key carriers) or a group id (group
|
|
19
|
+
carriers), and the return is a `{identity: summary}` mapping — `None` and `{}`
|
|
20
|
+
are equivalent (no records).
|
|
21
|
+
|
|
22
|
+
- old single-summary `None` (confirmed absent) → `{}` / key-not-present
|
|
23
|
+
- old single-summary hit → `{identity: summary}`
|
|
24
|
+
- duplicate / allow judgment paths are preserved verbatim
|
|
25
|
+
|
|
26
|
+
**Runtime breakage:** external custom `BackfillSource` implementations must
|
|
27
|
+
switch `load`'s return shape to the mapped form. Mechanical migration, no
|
|
28
|
+
other surface changed.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`RedisGroupDedupCarrier`** — a group-scoped `DedupCarrier`
|
|
33
|
+
(`guarantee="distributed"`) in `streamgate.contrib.redis_dedup`. A group
|
|
34
|
+
(batch / task / import session) is one Redis HASH
|
|
35
|
+
(`{prefix}dedup_group:{group}`, field = group-internal identity);
|
|
36
|
+
`group_key(record)` extracts the group id, `key(record)` the identity.
|
|
37
|
+
Three-stage admit: in-group HASH hit → group-exists fast path (**zero DB**) →
|
|
38
|
+
new-group cold load of the **whole group** (group-level single-flight +
|
|
39
|
+
concurrency gate). The group key's lifecycle is immutable — created only by a
|
|
40
|
+
successful whole-group backfill or the first placeholder after an
|
|
41
|
+
empty-group confirm — so a present group key makes in-group judgment
|
|
42
|
+
authoritative with no safety valve.
|
|
43
|
+
- **`RedisGroupDedupCache`** — HASH storage for the group carrier: atomic
|
|
44
|
+
per-field Lua placeholder, idempotent HSET+EXPIRE writes, chunked-pipeline
|
|
45
|
+
whole-group backfill, `group_exists` / `get_group_fields` / `get_ttl` /
|
|
46
|
+
`delete_group`.
|
|
47
|
+
- **`SqlBackfill(group_column=...)`** — optional group-mode backfill
|
|
48
|
+
(`WHERE group_column = :scope`, returns the whole group); unset keeps the
|
|
49
|
+
1.0.0 single-key behavior unchanged.
|
|
50
|
+
- `RedisConfig.group_key_prefix` / `RedisConfig.group_ttl_seconds` for the
|
|
51
|
+
group HASH keys.
|
|
52
|
+
|
|
53
|
+
### Notes
|
|
54
|
+
|
|
55
|
+
- The 1.0.0 single-key `RedisDedupCarrier` / `RedisDedupCache` keep their public
|
|
56
|
+
API; only their internal consumption of `load` moved to the mapped contract.
|
|
57
|
+
Both carriers coexist in one Redis instance (`dedup:` STRING vs
|
|
58
|
+
`dedup_group:` HASH, no type clash).
|
|
59
|
+
- Large active groups: whole-group backfill is chunked internally (implementation
|
|
60
|
+
detail — no config, no cap). Mind the `group_ttl_seconds` vs write-rate
|
|
61
|
+
relationship (idle-GC renewal grows the memory watermark of active groups);
|
|
62
|
+
a low-rate group that crosses its TTL mid-batch simply re-sources once, which
|
|
63
|
+
is normal self-healing.
|
|
64
|
+
|
|
8
65
|
## [1.0.0] - 2026-09-10
|
|
9
66
|
|
|
10
67
|
### BREAKING — both sides redesigned as flat entry points
|
|
@@ -94,13 +94,17 @@ These are plain constructor arguments (not env-mapped). All belong to
|
|
|
94
94
|
|-------|---------|-------------|
|
|
95
95
|
| `url` | `redis://localhost:6379/0` | Redis connection URL. |
|
|
96
96
|
| `key_prefix` | `streamgate:` | Key prefix for dedup keys (`<prefix>dedup:<identity>`). |
|
|
97
|
-
| `identity_ttl_seconds` | `18000` |
|
|
97
|
+
| `identity_ttl_seconds` | `18000` | Single-key placeholder/summary TTL (idle-GC heartbeat: every write renews it). |
|
|
98
|
+
| `group_key_prefix` | `dedup_group:` | Group-carrier key prefix (`<prefix>dedup_group:<group>` HASH; type-isolated from the single-key STRING). |
|
|
99
|
+
| `group_ttl_seconds` | `18000` | Group HASH TTL (idle-GC heartbeat: any group write renews it). |
|
|
98
100
|
| `socket_timeout_ms` | `1000` | Read/query path timeout. |
|
|
99
101
|
| `recv_timeout_ms` | `500` | Push path (reserve/summary write) timeout. |
|
|
100
102
|
|
|
101
103
|
Strategy-level knobs live on `RedisDedupCarrierConfig`
|
|
102
104
|
(`fail_closed_on_unavailable`, `cold_path_max_concurrency`, gate-full /
|
|
103
|
-
unavailable `retry_after` seconds, `log_context` mapping)
|
|
105
|
+
unavailable `retry_after` seconds, `log_context` mapping) and on
|
|
106
|
+
`RedisGroupDedupCarrierConfig` (`group_cold_path_max_concurrency`, same
|
|
107
|
+
family of knobs).
|
|
104
108
|
|
|
105
109
|
### `contrib.sql_upsert.DbConfig`
|
|
106
110
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: streamgate
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A pure-core Kafka data pipeline framework: conditional admission, reliable delivery, and one obvious consume-loop outlet via a typed handler.
|
|
5
5
|
Project-URL: Homepage, https://github.com/pwg-code/streamgate
|
|
6
6
|
Project-URL: Repository, https://github.com/pwg-code/streamgate
|
|
@@ -162,7 +162,7 @@ The package contains **zero web-framework code** (no fastapi/uvicorn), and the c
|
|
|
162
162
|
|
|
163
163
|
| Extra | Install | Subpackage(s) | Provides |
|
|
164
164
|
|-------|---------|---------------|----------|
|
|
165
|
-
| `[redis]` | `pip install "streamgate[redis]"` | `contrib.redis_dedup` | `RedisDedupCarrier` (`DedupCarrier` impl, `guarantee="distributed"`)
|
|
165
|
+
| `[redis]` | `pip install "streamgate[redis]"` | `contrib.redis_dedup` | `RedisDedupCarrier` (`DedupCarrier` impl, `guarantee="distributed"`) + `RedisDedupCache`, and the **group-scoped** `RedisGroupDedupCarrier` + `RedisGroupDedupCache` (whole-group one-shot cold backfill, group single-flight); `RedisDedupCarrierConfig`, `RedisConfig` |
|
|
166
166
|
| `[sql]` | `pip install "streamgate[sql]"` | `contrib.sql_upsert` / `contrib.sqlite_upsert` / `contrib.mssql_upsert` | `Upsert` + `upsert_outlet` (batch handler + single-record probe, SQLite `ON CONFLICT` + MSSQL `MERGE`+HOLDLOCK), `SqliteConsumer` / `MssqlConsumer` pre-assembled factories, engine factory, `SqlBackfill` (`BackfillSource` impl), `SQLAlchemyErrorClassifier`, `DbConfig` |
|
|
167
167
|
| `[http]` | `pip install "streamgate[http]"` | `contrib.http_probe` | `HttpProbeSignal` / `HysteresisController` (`BackpressureSignal` impl with trip/recover hysteresis, fail-closed probing) |
|
|
168
168
|
|
|
@@ -126,7 +126,7 @@ The package contains **zero web-framework code** (no fastapi/uvicorn), and the c
|
|
|
126
126
|
|
|
127
127
|
| Extra | Install | Subpackage(s) | Provides |
|
|
128
128
|
|-------|---------|---------------|----------|
|
|
129
|
-
| `[redis]` | `pip install "streamgate[redis]"` | `contrib.redis_dedup` | `RedisDedupCarrier` (`DedupCarrier` impl, `guarantee="distributed"`)
|
|
129
|
+
| `[redis]` | `pip install "streamgate[redis]"` | `contrib.redis_dedup` | `RedisDedupCarrier` (`DedupCarrier` impl, `guarantee="distributed"`) + `RedisDedupCache`, and the **group-scoped** `RedisGroupDedupCarrier` + `RedisGroupDedupCache` (whole-group one-shot cold backfill, group single-flight); `RedisDedupCarrierConfig`, `RedisConfig` |
|
|
130
130
|
| `[sql]` | `pip install "streamgate[sql]"` | `contrib.sql_upsert` / `contrib.sqlite_upsert` / `contrib.mssql_upsert` | `Upsert` + `upsert_outlet` (batch handler + single-record probe, SQLite `ON CONFLICT` + MSSQL `MERGE`+HOLDLOCK), `SqliteConsumer` / `MssqlConsumer` pre-assembled factories, engine factory, `SqlBackfill` (`BackfillSource` impl), `SQLAlchemyErrorClassifier`, `DbConfig` |
|
|
131
131
|
| `[http]` | `pip install "streamgate[http]"` | `contrib.http_probe` | `HttpProbeSignal` / `HysteresisController` (`BackpressureSignal` impl with trip/recover hysteresis, fail-closed probing) |
|
|
132
132
|
|
|
@@ -194,8 +194,9 @@ producer = Producer(..., options=ProducerOptions(
|
|
|
194
194
|
from streamgate.protocols import BackfillSource
|
|
195
195
|
|
|
196
196
|
class MyBackfill: # 能力清单只有一个方法
|
|
197
|
-
async def load(self,
|
|
198
|
-
... #
|
|
197
|
+
async def load(self, scope: str) -> dict[str, dict] | None:
|
|
198
|
+
... # 返回 {identity: summary} 映射;scope = 身份键(单键载体)
|
|
199
|
+
# 或组号(组载体,须返回该组全量身份);无记录返回 {}
|
|
199
200
|
# 核实不了就抛异常(按"不确定"处理:宁可拒,不重复)
|
|
200
201
|
|
|
201
202
|
carrier = MyCarrier(backfill=MyBackfill()) # 回源传给你的载体,不是 Producer!
|
|
@@ -241,7 +242,7 @@ producer = Producer(..., options=ProducerOptions(signal=MySignal())) # 注入
|
|
|
241
242
|
|
|
242
243
|
| Extra | 安装 | 子包 | 给你什么 |
|
|
243
244
|
|---|---|---|---|
|
|
244
|
-
| `[redis]` | `pip install "streamgate[redis]"` | `streamgate.contrib.redis_dedup` | Redis 分布式判重载体(多实例安全,Lua 原子占位/冷回源/fail-closed,`guarantee="distributed"
|
|
245
|
+
| `[redis]` | `pip install "streamgate[redis]"` | `streamgate.contrib.redis_dedup` | Redis 分布式判重载体(多实例安全,Lua 原子占位/冷回源/fail-closed,`guarantee="distributed"`);单身份键版 `RedisDedupCarrier` + 组版 `RedisGroupDedupCarrier`(组内候选判重) |
|
|
245
246
|
| `[sql]` | `pip install "streamgate[sql]"` | `streamgate.contrib.sqlite_upsert` / `.mssql_upsert` / `.sql_upsert` | SQL 幂等出口全套:`SqliteConsumer` / `MssqlConsumer` 预装配工厂(批量 upsert handler + 单条探针 + 数据库异常分类器三件套)、`upsert_outlet` 原语、引擎工厂、`SqlBackfill` 回源 |
|
|
246
247
|
| `[http]` | `pip install "streamgate[http]"` | `streamgate.contrib.http_probe` | 积压探针 + 磁滞限流状态机(trip/recover 防抖、fail-closed) |
|
|
247
248
|
|
|
@@ -272,6 +273,63 @@ if result.kind == "duplicate":
|
|
|
272
273
|
... # result.summary = 已有记录的摘要
|
|
273
274
|
```
|
|
274
275
|
|
|
276
|
+
同一场景,如果判重边界是"组"(批次/任务/导入会话:一个组号 + 组内身份不重复),
|
|
277
|
+
换 **`RedisGroupDedupCarrier`**——组是一整个 HASH,冷路径**整组一次回源预热**,
|
|
278
|
+
组内后续记录纯 Redis 命中,零 DB 查询;只有"全新组"才触发回源(组级单飞合并并发):
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from streamgate import DedupOptions, Producer, ProducerOptions
|
|
282
|
+
from streamgate.contrib.redis_dedup import (
|
|
283
|
+
RedisConfig, RedisGroupDedupCache, RedisGroupDedupCarrier,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
redis_config = RedisConfig(url="redis://localhost:6379/0")
|
|
287
|
+
producer = Producer(
|
|
288
|
+
"localhost:29092",
|
|
289
|
+
topic="orders",
|
|
290
|
+
options=ProducerOptions(
|
|
291
|
+
dedup=DedupOptions(
|
|
292
|
+
key=lambda r: r.order_id, # 组内身份键声明
|
|
293
|
+
carrier=RedisGroupDedupCarrier( # 仅组内判重(组 = 判重完整边界)
|
|
294
|
+
cache=RedisGroupDedupCache(redis_config),
|
|
295
|
+
group_key=lambda r: r.batch_no, # payload 组号(组 → 组内身份 HASH)
|
|
296
|
+
key=lambda r: r.order_id,
|
|
297
|
+
summary=lambda r: {"amount": r.amount},
|
|
298
|
+
redis_config=redis_config,
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
result = await producer.push(order)
|
|
304
|
+
if result.kind == "duplicate":
|
|
305
|
+
... # result.summary = 组内既有记录的摘要
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
组载体搭配 `SqlBackfill` 做冷回源时,只需加一个列名,**整组**一次查回预热:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from streamgate.contrib.sql_upsert import SqlBackfill
|
|
312
|
+
|
|
313
|
+
carrier = RedisGroupDedupCarrier(
|
|
314
|
+
cache=RedisGroupDedupCache(redis_config),
|
|
315
|
+
group_key=lambda r: r.batch_no,
|
|
316
|
+
key=lambda r: r.order_id,
|
|
317
|
+
backfill=SqlBackfill( # group_column 已设 → 整组回源
|
|
318
|
+
engine=...,
|
|
319
|
+
table="orders",
|
|
320
|
+
key_column="order_id",
|
|
321
|
+
summary_columns={"amount": "amount"},
|
|
322
|
+
order_columns=["seq_no"],
|
|
323
|
+
group_column="batch_no",
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
> **大组注意(仅备注,不构成限制)**:整组回填按 pipeline 分片写入(纯实现细节);
|
|
329
|
+
> 活跃大组的 Redis 内存水位随 `group_ttl_seconds`(idle-GC 续期)增长;单次回源
|
|
330
|
+
> 查询体积大时留意 `query_wait_seconds`(库内分页由回源实现自定);写入间隔接近
|
|
331
|
+
> TTL 的低速率组可能在批未结束时过期,触发一次额外回源属正常自愈。
|
|
332
|
+
|
|
275
333
|
SQL 出口的开箱路径——工厂预接三件套,其余与核心 `Consumer` 完全一致:
|
|
276
334
|
|
|
277
335
|
```python
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
核心层不得 import 本层;本层可使用核心公共 API 与所在 extra 的第三方库。
|
|
6
6
|
|
|
7
7
|
子包与 extras 对照(缺依赖时 import 会给出安装指引):
|
|
8
|
-
- redis_dedup:Redis 分布式判重载体(身份键原子占位 / 冷回源,guarantee="distributed"
|
|
8
|
+
- redis_dedup:Redis 分布式判重载体(身份键原子占位 / 冷回源,guarantee="distributed";
|
|
9
|
+
含组版 RedisGroupDedupCarrier:组 → 组内身份 HASH,整组一次冷回源)
|
|
9
10
|
→ pip install streamgate[redis]
|
|
10
11
|
- sql_upsert / sqlite_upsert / mssql_upsert:SQL 幂等 upsert 出口(基座 + 双方言预装配工厂)
|
|
11
12
|
→ pip install streamgate[sql]
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Redis 分布式判重载体(extras:streamgate[redis])。
|
|
2
2
|
|
|
3
3
|
RedisDedupCarrier(DedupCarrier 协议实现,guarantee="distributed")+
|
|
4
|
-
RedisDedupCache(身份键 → summary 缓存)+
|
|
5
|
-
|
|
4
|
+
RedisDedupCache(身份键 → summary 缓存)+ RedisGroupDedupCarrier/
|
|
5
|
+
RedisGroupDedupCache(组 → 组内身份 HASH 缓存)+ RedisConfig。
|
|
6
|
+
配合 streamgate.contrib.sql_upsert.SqlBackfill(可选 group_column 整组回源)
|
|
7
|
+
可实现冷身份/冷整组 DB 回源。
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from streamgate.contrib._deps import require_extra_import
|
|
@@ -20,6 +22,11 @@ try:
|
|
|
20
22
|
RejectReason,
|
|
21
23
|
)
|
|
22
24
|
from streamgate.contrib.redis_dedup.config import RedisConfig
|
|
25
|
+
from streamgate.contrib.redis_dedup.group_cache import RedisGroupDedupCache
|
|
26
|
+
from streamgate.contrib.redis_dedup.group_carrier import (
|
|
27
|
+
RedisGroupDedupCarrier,
|
|
28
|
+
RedisGroupDedupCarrierConfig,
|
|
29
|
+
)
|
|
23
30
|
except ModuleNotFoundError as e:
|
|
24
31
|
require_extra_import(e)
|
|
25
32
|
|
|
@@ -29,6 +36,9 @@ __all__ = [
|
|
|
29
36
|
"RedisDedupCache",
|
|
30
37
|
"RedisDedupCarrier",
|
|
31
38
|
"RedisDedupCarrierConfig",
|
|
39
|
+
"RedisGroupDedupCache",
|
|
40
|
+
"RedisGroupDedupCarrier",
|
|
41
|
+
"RedisGroupDedupCarrierConfig",
|
|
32
42
|
"RejectReason",
|
|
33
43
|
"dumps_summary",
|
|
34
44
|
"parse_summary",
|
|
@@ -243,7 +243,7 @@ class RedisDedupCarrier(Generic[RecordModelT]):
|
|
|
243
243
|
|
|
244
244
|
# ---- 阶段 2:冷身份回源(并发闸门;无回源时直接占位)----
|
|
245
245
|
try:
|
|
246
|
-
|
|
246
|
+
backfill_map = await self._gated_load(identity)
|
|
247
247
|
except ColdPathGateFullError:
|
|
248
248
|
logger.warning("cold_path_gate_rejected", **self._context(identity))
|
|
249
249
|
return self._reject_gate_full()
|
|
@@ -253,27 +253,28 @@ class RedisDedupCarrier(Generic[RecordModelT]):
|
|
|
253
253
|
)
|
|
254
254
|
return self._reject_dependency()
|
|
255
255
|
|
|
256
|
-
if
|
|
257
|
-
await self._backfill_best_effort(identity,
|
|
258
|
-
return Decision.duplicate(
|
|
256
|
+
if backfill_map is not None and identity in backfill_map:
|
|
257
|
+
await self._backfill_best_effort(identity, backfill_map[identity])
|
|
258
|
+
return Decision.duplicate(backfill_map[identity])
|
|
259
259
|
|
|
260
260
|
# DB 确认空:推入路径直接原子占位
|
|
261
261
|
return await self._reserve(identity, meta)
|
|
262
262
|
|
|
263
|
-
async def _gated_load(self,
|
|
263
|
+
async def _gated_load(self, scope: str) -> dict[str, JsonObject] | None:
|
|
264
264
|
"""闸门满时不触碰 DB,立即抛 ColdPathGateFullError。
|
|
265
265
|
|
|
266
|
+
回源契约:scope = 身份键(单键载体),返回 {identity: summary} 单条目映射。
|
|
266
267
|
单 event loop 内 locked() 检查与 acquire 之间无 await 点,无竞态窗口
|
|
267
268
|
(asyncio.Semaphore 槽位空闲时 acquire 不挂起)。
|
|
268
269
|
"""
|
|
269
270
|
if self._cold_gate is None:
|
|
270
|
-
logger.info("cold_path_load", **self._context(
|
|
271
|
-
return await self._backfill.load(
|
|
271
|
+
logger.info("cold_path_load", **self._context(scope))
|
|
272
|
+
return await self._backfill.load(scope)
|
|
272
273
|
if self._cold_gate.locked():
|
|
273
|
-
raise ColdPathGateFullError(
|
|
274
|
+
raise ColdPathGateFullError(scope)
|
|
274
275
|
async with self._cold_gate:
|
|
275
|
-
logger.info("cold_path_load", **self._context(
|
|
276
|
-
return await self._backfill.load(
|
|
276
|
+
logger.info("cold_path_load", **self._context(scope))
|
|
277
|
+
return await self._backfill.load(scope)
|
|
277
278
|
|
|
278
279
|
async def _reserve(
|
|
279
280
|
self, identity: str, meta: JsonObject
|
|
@@ -304,7 +305,7 @@ class RedisDedupCarrier(Generic[RecordModelT]):
|
|
|
304
305
|
error=str(error),
|
|
305
306
|
)
|
|
306
307
|
try:
|
|
307
|
-
|
|
308
|
+
backfill_map = await self._gated_load(identity)
|
|
308
309
|
except ColdPathGateFullError:
|
|
309
310
|
logger.warning("cold_path_gate_rejected", **self._context(identity))
|
|
310
311
|
return self._reject_gate_full()
|
|
@@ -315,8 +316,8 @@ class RedisDedupCarrier(Generic[RecordModelT]):
|
|
|
315
316
|
error=str(db_err),
|
|
316
317
|
)
|
|
317
318
|
return self._reject_dependency()
|
|
318
|
-
if
|
|
319
|
-
return Decision.duplicate(
|
|
319
|
+
if backfill_map is not None and identity in backfill_map:
|
|
320
|
+
return Decision.duplicate(backfill_map[identity])
|
|
320
321
|
return None
|
|
321
322
|
|
|
322
323
|
async def _backfill_best_effort(
|
|
@@ -9,6 +9,8 @@ from pydantic import BaseModel
|
|
|
9
9
|
class RedisConfig(BaseModel):
|
|
10
10
|
url: str = "redis://localhost:6379/0"
|
|
11
11
|
key_prefix: str = "streamgate:"
|
|
12
|
-
identity_ttl_seconds: int = 18000 #
|
|
12
|
+
identity_ttl_seconds: int = 18000 # 单键占位/摘要 TTL 5h(idle GC:写即续期回满额)
|
|
13
|
+
group_key_prefix: str = "dedup_group:" # 组键前缀(组载体 <prefix>dedup_group:<group>,与单键 STRING 隔离)
|
|
14
|
+
group_ttl_seconds: int = 18000 # 组 HASH TTL 5h(idle GC:组内任何写即续期回满额)
|
|
13
15
|
socket_timeout_ms: int = 1000 # 查询/校验路径超时
|
|
14
16
|
recv_timeout_ms: int = 500 # 推入路径占位/摘要写超时
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""RedisGroupDedupCache:组 → 组内身份 的 Redis HASH 缓存(分组判重载体的参考存储)。
|
|
2
|
+
|
|
3
|
+
机制(HASH,field = 组内身份,value = summary JSON):
|
|
4
|
+
- 原子组内占位(单 key Lua,Cluster 兼容):HGET 命中即返回既有摘要(竞态输家
|
|
5
|
+
不写入、不续期);未命中 HSET + EXPIRE(idle GC 续期)
|
|
6
|
+
- 幂等摘要写:HSET + EXPIRE 无条件续期(force / on_persisted / 回填共用)
|
|
7
|
+
- 整组回填 write_group_fields:pipeline 分片写入(分片为纯实现细节,无配置、无上界),
|
|
8
|
+
写后统一 EXPIRE
|
|
9
|
+
- 组存在探测 group_exists:EXISTS。组 key 生命周期不可变:仅"整组回填成功"或
|
|
10
|
+
"空组确认后首身份占位"两条路径创建 → 组 key 存在 ⇒ 组内判定可信,无需空组哨兵
|
|
11
|
+
- fail-closed 与否是判重载体的参数,缓存层只如实报告错误
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from collections.abc import Awaitable, Mapping
|
|
16
|
+
from typing import TypeVar, cast
|
|
17
|
+
|
|
18
|
+
import redis.asyncio as aioredis
|
|
19
|
+
|
|
20
|
+
from streamgate import logger
|
|
21
|
+
from streamgate.contrib.redis_dedup.cache import dumps_summary, parse_summary
|
|
22
|
+
from streamgate.contrib.redis_dedup.config import RedisConfig
|
|
23
|
+
from streamgate.protocols import JsonObject
|
|
24
|
+
|
|
25
|
+
_T = TypeVar("_T")
|
|
26
|
+
|
|
27
|
+
_HEALTH_TIMEOUT_S = 2.0
|
|
28
|
+
|
|
29
|
+
# 整组回填分片大小(纯实现细节:一次 HSET 的 field 数量上限,非业务配置)
|
|
30
|
+
_WRITE_CHUNK_SIZE = 200
|
|
31
|
+
|
|
32
|
+
# 原子组内占位(单 key,Cluster 兼容):命中即返回既有摘要,未命中写入 + TTL
|
|
33
|
+
# KEYS[1]=group key, ARGV=[identity, summary_json, ttl_seconds]
|
|
34
|
+
# 返回 [1, ""] = 占位成功;[0, existing_json] = 已被占(竞态输家未写入,不续期)
|
|
35
|
+
_RESERVE_LUA = """
|
|
36
|
+
local existing = redis.call('HGET', KEYS[1], ARGV[1])
|
|
37
|
+
if existing then
|
|
38
|
+
return {0, existing}
|
|
39
|
+
end
|
|
40
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
41
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
42
|
+
return {1, ''}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# 幂等摘要写(force 路径 / 消费端权威刷新 / 回填共用):无条件 HSET + 无条件续期
|
|
46
|
+
# KEYS[1]=group key, ARGV=[identity, summary_json, ttl_seconds]
|
|
47
|
+
_WRITE_LUA = """
|
|
48
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
49
|
+
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
|
|
50
|
+
return 1
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RedisGroupDedupCache:
|
|
55
|
+
def __init__(
|
|
56
|
+
self, config: RedisConfig, client: aioredis.Redis | None = None
|
|
57
|
+
) -> None:
|
|
58
|
+
self._config = config
|
|
59
|
+
self._injected = client
|
|
60
|
+
self._client: aioredis.Redis | None = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def group_ttl_seconds(self) -> int:
|
|
64
|
+
return self._config.group_ttl_seconds
|
|
65
|
+
|
|
66
|
+
async def start(self) -> None:
|
|
67
|
+
if self._client is not None:
|
|
68
|
+
return
|
|
69
|
+
if self._injected is not None:
|
|
70
|
+
self._client = self._injected
|
|
71
|
+
return
|
|
72
|
+
self._client = aioredis.from_url(
|
|
73
|
+
self._config.url,
|
|
74
|
+
socket_timeout=self._config.socket_timeout_ms / 1000,
|
|
75
|
+
socket_connect_timeout=self._config.socket_timeout_ms / 1000,
|
|
76
|
+
decode_responses=True,
|
|
77
|
+
)
|
|
78
|
+
logger.info("redis_client_created", url=self._config.url)
|
|
79
|
+
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
if self._client is None:
|
|
82
|
+
return
|
|
83
|
+
if self._injected is None: # 注入的客户端归调用方关闭
|
|
84
|
+
await self._client.aclose()
|
|
85
|
+
self._client = None
|
|
86
|
+
logger.info("redis_client_closed")
|
|
87
|
+
|
|
88
|
+
async def check_health_detail(self) -> tuple[bool, str | None]:
|
|
89
|
+
"""健康预检。返回 (是否可用, 错误信息);可用时 error 为 None。"""
|
|
90
|
+
if self._client is None:
|
|
91
|
+
return False, "redis client not started"
|
|
92
|
+
try:
|
|
93
|
+
await asyncio.wait_for(self._client.ping(), timeout=_HEALTH_TIMEOUT_S)
|
|
94
|
+
return True, None
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.debug("redis_health_check_failed", error=str(e))
|
|
97
|
+
return False, str(e)
|
|
98
|
+
|
|
99
|
+
async def check_health(self) -> bool:
|
|
100
|
+
ok, _ = await self.check_health_detail()
|
|
101
|
+
return ok
|
|
102
|
+
|
|
103
|
+
def group_key(self, group: str) -> str:
|
|
104
|
+
return f"{self._config.key_prefix}{self._config.group_key_prefix}{group}"
|
|
105
|
+
|
|
106
|
+
# ---------- 内部工具 ----------
|
|
107
|
+
|
|
108
|
+
def _require_client(self) -> aioredis.Redis:
|
|
109
|
+
if self._client is None:
|
|
110
|
+
raise RuntimeError("RedisGroupDedupCache not started, call start() first")
|
|
111
|
+
return self._client
|
|
112
|
+
|
|
113
|
+
async def _read(self, coro: Awaitable[_T]) -> _T:
|
|
114
|
+
"""读操作统一超时(查询/校验路径 socket_timeout_ms)。"""
|
|
115
|
+
return await asyncio.wait_for(
|
|
116
|
+
coro, timeout=self._config.socket_timeout_ms / 1000
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def _write(self, coro: Awaitable[_T]) -> _T:
|
|
120
|
+
"""写操作统一超时(推入路径 recv_timeout_ms)。"""
|
|
121
|
+
return await asyncio.wait_for(coro, timeout=self._config.recv_timeout_ms / 1000)
|
|
122
|
+
|
|
123
|
+
# ---------- 读 ----------
|
|
124
|
+
|
|
125
|
+
async def get_identity_meta(self, group: str, identity: str) -> JsonObject | None:
|
|
126
|
+
# redis-py 命令类型为 ResponseT(Awaitable|值 联合,同步模式兼容);
|
|
127
|
+
# 异步 client 恒返回 Awaitable,cast 仅为收窄静态类型
|
|
128
|
+
raw = await self._read(
|
|
129
|
+
cast(
|
|
130
|
+
"Awaitable[str | None]",
|
|
131
|
+
self._require_client().hget(self.group_key(group), identity),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if raw is None:
|
|
135
|
+
return None
|
|
136
|
+
return parse_summary(str(raw))
|
|
137
|
+
|
|
138
|
+
async def get_group_fields(self, group: str) -> dict[str, JsonObject]:
|
|
139
|
+
raw = await self._read(
|
|
140
|
+
cast(
|
|
141
|
+
"Awaitable[dict[str, str]]",
|
|
142
|
+
self._require_client().hgetall(self.group_key(group)),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return {identity: parse_summary(str(payload)) for identity, payload in raw.items()}
|
|
146
|
+
|
|
147
|
+
async def get_ttl(self, group: str) -> int:
|
|
148
|
+
return int(
|
|
149
|
+
await self._read(self._require_client().ttl(self.group_key(group)))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def group_exists(self, group: str) -> bool:
|
|
153
|
+
hits = int(
|
|
154
|
+
await self._read(self._require_client().exists(self.group_key(group)))
|
|
155
|
+
)
|
|
156
|
+
return hits > 0
|
|
157
|
+
|
|
158
|
+
# ---------- 写 ----------
|
|
159
|
+
|
|
160
|
+
async def reserve(
|
|
161
|
+
self, group: str, identity: str, summary: JsonObject
|
|
162
|
+
) -> tuple[bool, JsonObject | None]:
|
|
163
|
+
"""原子检查 + 组内占位。返回 (True, None) = 占位成功;(False, 既有摘要) = 已被占。"""
|
|
164
|
+
client = self._require_client()
|
|
165
|
+
script = client.register_script(_RESERVE_LUA)
|
|
166
|
+
result = await self._write(
|
|
167
|
+
script(
|
|
168
|
+
keys=[self.group_key(group)],
|
|
169
|
+
args=[identity, dumps_summary(summary), self._config.group_ttl_seconds],
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
reserved, existing = int(result[0]), result[1] # type: ignore[index]
|
|
173
|
+
if reserved == 1:
|
|
174
|
+
return (True, None)
|
|
175
|
+
return (False, parse_summary(str(existing)))
|
|
176
|
+
|
|
177
|
+
async def write_summary(self, group: str, identity: str, summary: JsonObject) -> None:
|
|
178
|
+
"""幂等摘要写(force 路径 / 消费端权威刷新),无条件续期。"""
|
|
179
|
+
client = self._require_client()
|
|
180
|
+
script = client.register_script(_WRITE_LUA)
|
|
181
|
+
await self._write(
|
|
182
|
+
script(
|
|
183
|
+
keys=[self.group_key(group)],
|
|
184
|
+
args=[identity, dumps_summary(summary), self._config.group_ttl_seconds],
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
async def write_group_fields(self, group: str, mapping: dict[str, JsonObject]) -> None:
|
|
189
|
+
"""整组回填:pipeline 分片写入(纯实现细节),写后统一续期。
|
|
190
|
+
|
|
191
|
+
best-effort 语义(失败抛异常,由调用方 WARN 告警):
|
|
192
|
+
- 冷路径:本方法之前组 key 不存在,首片写入即建组;分片中途失败时
|
|
193
|
+
半组对上层不可判定(阶段2组存在快路径要求 group_exists=True 才走零 DB,
|
|
194
|
+
判定路径在回填抛异常时统一 REJECT,不把半组当全集)
|
|
195
|
+
- 幂等:对既有组重复回填全量映射无害(HASH 覆盖写 + 续期)
|
|
196
|
+
"""
|
|
197
|
+
if not mapping:
|
|
198
|
+
return
|
|
199
|
+
client = self._require_client()
|
|
200
|
+
key = self.group_key(group)
|
|
201
|
+
items = [
|
|
202
|
+
(identity, dumps_summary(summary)) for identity, summary in mapping.items()
|
|
203
|
+
]
|
|
204
|
+
for i in range(0, len(items), _WRITE_CHUNK_SIZE):
|
|
205
|
+
chunk = dict(items[i : i + _WRITE_CHUNK_SIZE])
|
|
206
|
+
pipe = client.pipeline()
|
|
207
|
+
pipe.hset(key, mapping=cast("Mapping[str, str]", chunk)) # type: ignore[reportArgumentType]
|
|
208
|
+
pipe.expire(key, self._config.group_ttl_seconds)
|
|
209
|
+
await self._write(pipe.execute())
|
|
210
|
+
|
|
211
|
+
async def delete_group(self, group: str) -> None:
|
|
212
|
+
"""删除整组(供告警/运维删键修复)。"""
|
|
213
|
+
await self._write(self._require_client().delete(self.group_key(group)))
|