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.
Files changed (79) hide show
  1. {streamgate-1.0.0 → streamgate-2.0.0}/.github/workflows/ci.yml +9 -4
  2. {streamgate-1.0.0 → streamgate-2.0.0}/CHANGELOG.md +57 -0
  3. {streamgate-1.0.0 → streamgate-2.0.0}/CONFIGURATION.md +6 -2
  4. {streamgate-1.0.0 → streamgate-2.0.0}/PKG-INFO +2 -2
  5. {streamgate-1.0.0 → streamgate-2.0.0}/README.md +1 -1
  6. {streamgate-1.0.0 → streamgate-2.0.0}/README.zh-CN.md +61 -3
  7. {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/produce.py +1 -1
  8. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/__init__.py +1 -1
  9. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/__init__.py +2 -1
  10. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/__init__.py +12 -2
  11. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/carrier.py +14 -13
  12. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/config.py +3 -1
  13. streamgate-2.0.0/src/streamgate/contrib/redis_dedup/group_cache.py +213 -0
  14. streamgate-2.0.0/src/streamgate/contrib/redis_dedup/group_carrier.py +472 -0
  15. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/backfill.py +45 -15
  16. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/protocols.py +11 -4
  17. {streamgate-1.0.0 → streamgate-2.0.0}/.github/workflows/release.yml +0 -0
  18. {streamgate-1.0.0 → streamgate-2.0.0}/.gitignore +0 -0
  19. {streamgate-1.0.0 → streamgate-2.0.0}/AGENTS.md +0 -0
  20. {streamgate-1.0.0 → streamgate-2.0.0}/CONTRIBUTING.md +0 -0
  21. {streamgate-1.0.0 → streamgate-2.0.0}/LICENSE +0 -0
  22. {streamgate-1.0.0 → streamgate-2.0.0}/examples/docker-compose.yml +0 -0
  23. {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/README.md +0 -0
  24. {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/consume.py +0 -0
  25. {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/health_server.py +0 -0
  26. {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/models.py +0 -0
  27. {streamgate-1.0.0 → streamgate-2.0.0}/examples/http_probe/produce.py +0 -0
  28. {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/README.md +0 -0
  29. {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/consume.py +0 -0
  30. {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/health_server.py +0 -0
  31. {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/models.py +0 -0
  32. {streamgate-1.0.0 → streamgate-2.0.0}/examples/prod_pipeline/produce.py +0 -0
  33. {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/README.md +0 -0
  34. {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/consume.py +0 -0
  35. {streamgate-1.0.0 → streamgate-2.0.0}/examples/pure_pipeline/models.py +0 -0
  36. {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/README.md +0 -0
  37. {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/models.py +0 -0
  38. {streamgate-1.0.0 → streamgate-2.0.0}/examples/redis_admission/produce.py +0 -0
  39. {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/README.md +0 -0
  40. {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/consume.py +0 -0
  41. {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/models.py +0 -0
  42. {streamgate-1.0.0 → streamgate-2.0.0}/examples/sqlite_upsert/produce.py +0 -0
  43. {streamgate-1.0.0 → streamgate-2.0.0}/pyproject.toml +0 -0
  44. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/config.py +0 -0
  45. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/__init__.py +0 -0
  46. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/classifier.py +0 -0
  47. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/dlq.py +0 -0
  48. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/loop.py +0 -0
  49. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/options.py +0 -0
  50. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/consumer/runner.py +0 -0
  51. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/_deps.py +0 -0
  52. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
  53. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
  54. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/mssql_upsert/__init__.py +0 -0
  55. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/redis_dedup/cache.py +0 -0
  56. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/__init__.py +0 -0
  57. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/__init__.py +0 -0
  58. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/mssql.py +0 -0
  59. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/_dialects/sqlite.py +0 -0
  60. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/classifier.py +0 -0
  61. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/config.py +0 -0
  62. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/engines.py +0 -0
  63. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/factory.py +0 -0
  64. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sql_upsert/upsert.py +0 -0
  65. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/contrib/sqlite_upsert/__init__.py +0 -0
  66. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/__init__.py +0 -0
  67. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/dedup/__init__.py +0 -0
  68. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/dedup/in_memory.py +0 -0
  69. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/ingest/producer.py +0 -0
  70. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/__init__.py +0 -0
  71. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/logging.py +0 -0
  72. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/obs/metrics.py +0 -0
  73. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/__init__.py +0 -0
  74. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/backpressure.py +0 -0
  75. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/resilience/health.py +0 -0
  76. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/__init__.py +0 -0
  77. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/codec.py +0 -0
  78. {streamgate-1.0.0 → streamgate-2.0.0}/src/streamgate/transport/kafka.py +0 -0
  79. {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, DefaultErrorClassifier, ErrorKind,
104
- InMemoryAdmission, IngestBinding, IngestGateway,
105
- ManualBackpressureSignal,
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: conflict" produce.out # in-memory uniqueness contract
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` | Placeholder/summary TTL (idle-GC heartbeat: every write renews it). |
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: 1.0.0
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"`), `RedisDedupCache`, `RedisDedupCarrierConfig`, `RedisConfig` |
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"`), `RedisDedupCache`, `RedisDedupCarrierConfig`, `RedisConfig` |
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, identity: str) -> dict | None:
198
- ... # 返回既有摘要 dict;None = 确认不存在
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
@@ -28,7 +28,7 @@ async def main() -> None:
28
28
  options=ProducerOptions(
29
29
  message_type="order",
30
30
  # 一行开启进程内判重:身份键 = order_id,duplicate 时回给已有摘要
31
- dedup=DedupOptions(
31
+ dedup=DedupOptions[OrderIn](
32
32
  key=lambda r: r.order_id,
33
33
  summary=lambda r: {"amount": r.amount},
34
34
  ),
@@ -85,7 +85,7 @@ from streamgate.resilience.health import (
85
85
  from streamgate.transport.codec import JsonEnvelopeCodec
86
86
  from streamgate.transport.kafka import KafkaConsumerService
87
87
 
88
- __version__ = "1.0.0"
88
+ __version__ = "2.0.0"
89
89
 
90
90
  __all__ = [
91
91
  "AllowAllSignal",
@@ -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 缓存)+ RedisConfig。配合
5
- streamgate.contrib.sql_upsert.SqlBackfill 可实现冷身份 DB 回源。
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
- backfill_summary = await self._gated_load(identity)
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 backfill_summary is not None:
257
- await self._backfill_best_effort(identity, backfill_summary)
258
- return Decision.duplicate(backfill_summary)
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, identity: str) -> JsonObject | None:
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(identity))
271
- return await self._backfill.load(identity)
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(identity)
274
+ raise ColdPathGateFullError(scope)
274
275
  async with self._cold_gate:
275
- logger.info("cold_path_load", **self._context(identity))
276
- return await self._backfill.load(identity)
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
- backfill_summary = await self._gated_load(identity)
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 backfill_summary is not None:
319
- return Decision.duplicate(backfill_summary)
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 # 占位/摘要 TTL 5h(idle GC:写即续期回满额)
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)))