streamgate 2.0.0__tar.gz → 2.1.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-2.0.0 → streamgate-2.1.0}/CHANGELOG.md +23 -0
  2. {streamgate-2.0.0 → streamgate-2.1.0}/PKG-INFO +1 -1
  3. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/__init__.py +1 -1
  4. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/__init__.py +4 -0
  5. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/group_cache.py +35 -4
  6. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/group_carrier.py +31 -2
  7. {streamgate-2.0.0 → streamgate-2.1.0}/.github/workflows/ci.yml +0 -0
  8. {streamgate-2.0.0 → streamgate-2.1.0}/.github/workflows/release.yml +0 -0
  9. {streamgate-2.0.0 → streamgate-2.1.0}/.gitignore +0 -0
  10. {streamgate-2.0.0 → streamgate-2.1.0}/AGENTS.md +0 -0
  11. {streamgate-2.0.0 → streamgate-2.1.0}/CONFIGURATION.md +0 -0
  12. {streamgate-2.0.0 → streamgate-2.1.0}/CONTRIBUTING.md +0 -0
  13. {streamgate-2.0.0 → streamgate-2.1.0}/LICENSE +0 -0
  14. {streamgate-2.0.0 → streamgate-2.1.0}/README.md +0 -0
  15. {streamgate-2.0.0 → streamgate-2.1.0}/README.zh-CN.md +0 -0
  16. {streamgate-2.0.0 → streamgate-2.1.0}/examples/docker-compose.yml +0 -0
  17. {streamgate-2.0.0 → streamgate-2.1.0}/examples/http_probe/README.md +0 -0
  18. {streamgate-2.0.0 → streamgate-2.1.0}/examples/http_probe/consume.py +0 -0
  19. {streamgate-2.0.0 → streamgate-2.1.0}/examples/http_probe/health_server.py +0 -0
  20. {streamgate-2.0.0 → streamgate-2.1.0}/examples/http_probe/models.py +0 -0
  21. {streamgate-2.0.0 → streamgate-2.1.0}/examples/http_probe/produce.py +0 -0
  22. {streamgate-2.0.0 → streamgate-2.1.0}/examples/prod_pipeline/README.md +0 -0
  23. {streamgate-2.0.0 → streamgate-2.1.0}/examples/prod_pipeline/consume.py +0 -0
  24. {streamgate-2.0.0 → streamgate-2.1.0}/examples/prod_pipeline/health_server.py +0 -0
  25. {streamgate-2.0.0 → streamgate-2.1.0}/examples/prod_pipeline/models.py +0 -0
  26. {streamgate-2.0.0 → streamgate-2.1.0}/examples/prod_pipeline/produce.py +0 -0
  27. {streamgate-2.0.0 → streamgate-2.1.0}/examples/pure_pipeline/README.md +0 -0
  28. {streamgate-2.0.0 → streamgate-2.1.0}/examples/pure_pipeline/consume.py +0 -0
  29. {streamgate-2.0.0 → streamgate-2.1.0}/examples/pure_pipeline/models.py +0 -0
  30. {streamgate-2.0.0 → streamgate-2.1.0}/examples/pure_pipeline/produce.py +0 -0
  31. {streamgate-2.0.0 → streamgate-2.1.0}/examples/redis_admission/README.md +0 -0
  32. {streamgate-2.0.0 → streamgate-2.1.0}/examples/redis_admission/models.py +0 -0
  33. {streamgate-2.0.0 → streamgate-2.1.0}/examples/redis_admission/produce.py +0 -0
  34. {streamgate-2.0.0 → streamgate-2.1.0}/examples/sqlite_upsert/README.md +0 -0
  35. {streamgate-2.0.0 → streamgate-2.1.0}/examples/sqlite_upsert/consume.py +0 -0
  36. {streamgate-2.0.0 → streamgate-2.1.0}/examples/sqlite_upsert/models.py +0 -0
  37. {streamgate-2.0.0 → streamgate-2.1.0}/examples/sqlite_upsert/produce.py +0 -0
  38. {streamgate-2.0.0 → streamgate-2.1.0}/pyproject.toml +0 -0
  39. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/config.py +0 -0
  40. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/__init__.py +0 -0
  41. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/classifier.py +0 -0
  42. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/dlq.py +0 -0
  43. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/loop.py +0 -0
  44. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/options.py +0 -0
  45. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/consumer/runner.py +0 -0
  46. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/__init__.py +0 -0
  47. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/_deps.py +0 -0
  48. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
  49. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
  50. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/mssql_upsert/__init__.py +0 -0
  51. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/cache.py +0 -0
  52. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/carrier.py +0 -0
  53. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/redis_dedup/config.py +0 -0
  54. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/__init__.py +0 -0
  55. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/_dialects/__init__.py +0 -0
  56. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/_dialects/mssql.py +0 -0
  57. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/_dialects/sqlite.py +0 -0
  58. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/backfill.py +0 -0
  59. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/classifier.py +0 -0
  60. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/config.py +0 -0
  61. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/engines.py +0 -0
  62. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/factory.py +0 -0
  63. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sql_upsert/upsert.py +0 -0
  64. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/contrib/sqlite_upsert/__init__.py +0 -0
  65. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/ingest/__init__.py +0 -0
  66. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/ingest/dedup/__init__.py +0 -0
  67. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/ingest/dedup/in_memory.py +0 -0
  68. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/ingest/producer.py +0 -0
  69. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/obs/__init__.py +0 -0
  70. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/obs/logging.py +0 -0
  71. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/obs/metrics.py +0 -0
  72. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/protocols.py +0 -0
  73. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/resilience/__init__.py +0 -0
  74. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/resilience/backpressure.py +0 -0
  75. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/resilience/health.py +0 -0
  76. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/transport/__init__.py +0 -0
  77. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/transport/codec.py +0 -0
  78. {streamgate-2.0.0 → streamgate-2.1.0}/src/streamgate/transport/kafka.py +0 -0
  79. {streamgate-2.0.0 → streamgate-2.1.0}/uv.lock +0 -0
@@ -5,6 +5,29 @@ 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.1.0] - 2026-09-11
9
+
10
+ ### Added
11
+
12
+ - **`RedisGroupDedupCache.confirm_empty(group)`** — an explicit "confirmed
13
+ empty" negative-cache state for group keys. Writes a reserved marker field
14
+ into the group HASH (`_GROUP_EMPTY_MARKER`, TTL follows
15
+ `group_ttl_seconds`, idle-GC); afterwards `group_exists()` is `True` and
16
+ `get_group_fields()` returns `{}`, so enumeration queries stop re-sourcing a
17
+ known-empty group from the DB on every call. `get_identity_meta` /
18
+ `get_group_fields` filter the marker; `group_exists` semantics are unchanged
19
+ ("key present ⇒ group state authoritative, possibly empty"). Purely additive
20
+ — no existing signature changed.
21
+ - **`RedisGroupDedupCarrier.load_group_refill(group)`** — public primitive
22
+ exposing the whole-group cold load + backfill path for reuse by enumeration
23
+ (query) sides instead of copying the mechanism: group-level single-flight,
24
+ concurrency gate, chunked backfill and the "only a successful whole-group
25
+ backfill creates the key" invariant are all shared with `admit` stage 3. It
26
+ returns a `GroupLoadOutcome` (`FOUND` / `EMPTY` / `FAILED` / `GATE_FULL`);
27
+ policy (endpoints, response bodies, HTTP 429/502, `source` labels) stays in
28
+ the caller's adapter layer. `GroupLoadOutcome` / `GroupLoadResult` are now
29
+ exported from `streamgate.contrib.redis_dedup`.
30
+
8
31
  ## [2.0.0] - 2026-09-11
9
32
 
10
33
  ### BREAKING — unified backfill contract (feat!)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: streamgate
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -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__ = "2.0.0"
88
+ __version__ = "2.1.0"
89
89
 
90
90
  __all__ = [
91
91
  "AllowAllSignal",
@@ -24,6 +24,8 @@ try:
24
24
  from streamgate.contrib.redis_dedup.config import RedisConfig
25
25
  from streamgate.contrib.redis_dedup.group_cache import RedisGroupDedupCache
26
26
  from streamgate.contrib.redis_dedup.group_carrier import (
27
+ GroupLoadOutcome,
28
+ GroupLoadResult,
27
29
  RedisGroupDedupCarrier,
28
30
  RedisGroupDedupCarrierConfig,
29
31
  )
@@ -32,6 +34,8 @@ except ModuleNotFoundError as e:
32
34
 
33
35
  __all__ = [
34
36
  "ColdPathGateFullError",
37
+ "GroupLoadOutcome",
38
+ "GroupLoadResult",
35
39
  "RedisConfig",
36
40
  "RedisDedupCache",
37
41
  "RedisDedupCarrier",
@@ -6,8 +6,12 @@
6
6
  - 幂等摘要写:HSET + EXPIRE 无条件续期(force / on_persisted / 回填共用)
7
7
  - 整组回填 write_group_fields:pipeline 分片写入(分片为纯实现细节,无配置、无上界),
8
8
  写后统一 EXPIRE
9
- - 组存在探测 group_exists:EXISTS。组 key 生命周期不可变:仅"整组回填成功"或
10
- "空组确认后首身份占位"两条路径创建 → 组 key 存在 ⇒ 组内判定可信,无需空组哨兵
9
+ - 组存在探测 group_exists:EXISTS。组 key 生命周期不可变:仅"整组回填成功"、
10
+ "空组确认后首身份占位"、"整组确认空标记 confirm_empty"三条路径创建 →
11
+ 组 key 存在 ⇒ 组状态可信(含"已确认空"负缓存态),判定快路径照常成立
12
+ - "确认空组"以专用 field 标记(_GROUP_EMPTY_MARKER)落 HASH:枚举查询侧
13
+ group_exists=True 直接零 DB,get_group_fields 过滤该标记返回 {};判重侧
14
+ phase 2 快路径不变(field 缺失 = 组内确认无此身份)
11
15
  - fail-closed 与否是判重载体的参数,缓存层只如实报告错误
12
16
  """
13
17
 
@@ -29,6 +33,12 @@ _HEALTH_TIMEOUT_S = 2.0
29
33
  # 整组回填分片大小(纯实现细节:一次 HSET 的 field 数量上限,非业务配置)
30
34
  _WRITE_CHUNK_SIZE = 200
31
35
 
36
+ # "整组确认空"负缓存标记:作为独立 field 写入组 HASH,标记该组已获 DB 确认无记录。
37
+ # 组 key 因此存在(group_exists=True),枚举查询侧不再判"冷"反复整组回源;
38
+ # 判定快路径语义不变(field 缺失 = 组内确认无此身份)。取值需保证不可能与
39
+ # 真实组内身份重名。
40
+ _GROUP_EMPTY_MARKER = "__streamgate_group_empty__"
41
+
32
42
  # 原子组内占位(单 key,Cluster 兼容):命中即返回既有摘要,未命中写入 + TTL
33
43
  # KEYS[1]=group key, ARGV=[identity, summary_json, ttl_seconds]
34
44
  # 返回 [1, ""] = 占位成功;[0, existing_json] = 已被占(竞态输家未写入,不续期)
@@ -131,7 +141,7 @@ class RedisGroupDedupCache:
131
141
  self._require_client().hget(self.group_key(group), identity),
132
142
  )
133
143
  )
134
- if raw is None:
144
+ if raw is None or identity == _GROUP_EMPTY_MARKER:
135
145
  return None
136
146
  return parse_summary(str(raw))
137
147
 
@@ -142,7 +152,11 @@ class RedisGroupDedupCache:
142
152
  self._require_client().hgetall(self.group_key(group)),
143
153
  )
144
154
  )
145
- return {identity: parse_summary(str(payload)) for identity, payload in raw.items()}
155
+ return {
156
+ identity: parse_summary(str(payload))
157
+ for identity, payload in raw.items()
158
+ if identity != _GROUP_EMPTY_MARKER
159
+ }
146
160
 
147
161
  async def get_ttl(self, group: str) -> int:
148
162
  return int(
@@ -150,6 +164,8 @@ class RedisGroupDedupCache:
150
164
  )
151
165
 
152
166
  async def group_exists(self, group: str) -> bool:
167
+ """组存在探测。组 key 存在 ⇒ 组状态可信(含"已确认空"负缓存态),
168
+ 判定快路径照常成立:field 缺失 = DB 内确认无此身份。"""
153
169
  hits = int(
154
170
  await self._read(self._require_client().exists(self.group_key(group)))
155
171
  )
@@ -208,6 +224,21 @@ class RedisGroupDedupCache:
208
224
  pipe.expire(key, self._config.group_ttl_seconds)
209
225
  await self._write(pipe.execute())
210
226
 
227
+ async def confirm_empty(self, group: str) -> None:
228
+ """标记"整组确认空"负缓存态:写专用 marker + EXPIRE(TTL 沿用
229
+ group_ttl_seconds,idle GC)。
230
+
231
+ 此后 group_exists() 为 True(键存在 ⇒ 组状态可信,可能为空)且
232
+ get_group_fields() 返回 {},枚举查询侧不再判"冷"反复整组回源。
233
+ 判重 phase 2 快路径不变:组内 field 缺失 = DB 确认无此身份 → 占位。
234
+ 调用前提由使用方保证:仅在 DB 确认组无记录后调用(写库后无自我失效
235
+ 语义)。
236
+
237
+ marker 已被 get_identity_meta / get_group_fields 过滤,不会以真实
238
+ 身份或字段泄漏给上层。
239
+ """
240
+ await self.write_summary(group, _GROUP_EMPTY_MARKER, {})
241
+
211
242
  async def delete_group(self, group: str) -> None:
212
243
  """删除整组(供告警/运维删键修复)。"""
213
244
  await self._write(self._require_client().delete(self.group_key(group)))
@@ -4,8 +4,11 @@
4
4
  - 组键 = HASH(field = 组内身份),以组为单位缓存,整组一次冷回源预热
5
5
  - admit 三阶段:组内缓存判定 → 组存在快路径(零 DB)→ 全新组冷回源
6
6
  (组级单飞 + 并发闸门)
7
- - 组 key 生命周期不可变:仅"整组回填成功"或"空组确认后首身份占位"两条路径
8
- 创建 → 组 key 存在 ⇒ 组内判定可信(无需任何安全阀/开关)
7
+ - 组 key 生命周期不可变:仅"整组回填成功"、"空组确认后首身份占位"、
8
+ "整组确认空标记(cache.confirm_empty)"三条路径创建 → 组 key 存在 ⇒
9
+ 组状态可信(可能为空),无需任何安全阀/开关
10
+ - load_group_refill(group) 公开"整组冷回源 + 回填建键"原语:复用组级单飞 +
11
+ 并发闸门 + 分片回填(与 admit 阶段3 同一机制),供枚举查询侧复用避免复制
9
12
 
10
13
  框架保证调用时序:admit → (Kafka 发送) → on_send_success(每次成功,通知型)/
11
14
  on_send_failed(每次失败,默认保留占位 TTL 自愈)→ on_force_accepted(仅
@@ -365,6 +368,31 @@ class RedisGroupDedupCarrier(Generic[RecordModelT]):
365
368
  mapping=fields,
366
369
  )
367
370
 
371
+ # ---- 公开原语:整组冷回源 + 回填(枚举查询侧复用,避免复制机制)----
372
+
373
+ async def load_group_refill(self, group: str) -> GroupLoadOutcome:
374
+ """整组冷回源 + 回填建键(公开原语,供枚举查询侧复用)。
375
+
376
+ 与 admit 阶段3 复用同一机制与不变式:
377
+ - 组级单飞:同组并发首触只回源一次,Future 共享;等待方不悬挂;
378
+ - 并发闸门:闸门满返回 GATE_FULL 态,不触碰 DB(REJECT 方向);
379
+ - 整组分片回填后统一续期;仅整组回填成功才建键(write_group_fields
380
+ 分片写出自动建键),半组/失败不成键、不对外判定。
381
+
382
+ 返回 GroupLoadOutcome(三态):
383
+ - FOUND:组内字段已回填,使用方 get_group_fields(group) 直接命中;
384
+ - EMPTY:DB 确认组无记录,使用方可选 cache.confirm_empty(group)
385
+ 落"确认空"负缓存,消除后续冷回源;
386
+ - FAILED / GATE_FULL:不建键,由使用方按策略处置。
387
+
388
+ 边界:本方法只暴露机制,不纳入枚举查询端点/响应体/HTTP 429-502/
389
+ source 标签等策略内容(mechanism vs policy)。
390
+ """
391
+ outcome = await self._load_group_single_flight(group)
392
+ if outcome.result is GroupLoadResult.FOUND:
393
+ await self._backfill_best_effort(group, outcome.mapping)
394
+ return outcome
395
+
368
396
  # ---- 占位与回填 ----
369
397
 
370
398
  async def _reserve(
@@ -466,6 +494,7 @@ class RedisGroupDedupCarrier(Generic[RecordModelT]):
466
494
 
467
495
 
468
496
  __all__ = [
497
+ "GroupLoadOutcome",
469
498
  "GroupLoadResult",
470
499
  "RedisGroupDedupCarrier",
471
500
  "RedisGroupDedupCarrierConfig",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes