streamgate 0.1.0__tar.gz → 0.2.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 (80) hide show
  1. streamgate-0.2.0/.github/workflows/ci.yml +133 -0
  2. streamgate-0.2.0/.github/workflows/release.yml +41 -0
  3. streamgate-0.2.0/.import_linter_cache/5780e4798d429798e55d962d9844c38a9b5e0494.data.json +1 -0
  4. streamgate-0.2.0/.import_linter_cache/a1215eee6d5c7b9f11de3767d6965c4a9c703b3e.data.json +1 -0
  5. streamgate-0.2.0/.import_linter_cache/streamgate.meta.json +1 -0
  6. streamgate-0.2.0/CHANGELOG.md +116 -0
  7. streamgate-0.2.0/CONFIGURATION.md +79 -0
  8. streamgate-0.2.0/CONTRIBUTING.md +40 -0
  9. streamgate-0.2.0/PKG-INFO +170 -0
  10. streamgate-0.2.0/README.md +143 -0
  11. streamgate-0.2.0/README.zh-CN.md +284 -0
  12. streamgate-0.2.0/examples/docker-compose.yml +35 -0
  13. streamgate-0.2.0/examples/http_probe/README.md +45 -0
  14. streamgate-0.2.0/examples/http_probe/consume.py +53 -0
  15. streamgate-0.2.0/examples/http_probe/health_server.py +46 -0
  16. streamgate-0.2.0/examples/http_probe/models.py +10 -0
  17. streamgate-0.1.0/src/streamgate/resilience/backpressure.py → streamgate-0.2.0/examples/http_probe/probe_signal.py +358 -370
  18. streamgate-0.2.0/examples/http_probe/produce.py +80 -0
  19. streamgate-0.2.0/examples/pure_pipeline/README.md +59 -0
  20. streamgate-0.2.0/examples/pure_pipeline/consume.py +80 -0
  21. streamgate-0.2.0/examples/pure_pipeline/models.py +14 -0
  22. streamgate-0.2.0/examples/pure_pipeline/produce.py +69 -0
  23. streamgate-0.2.0/examples/redis_admission/README.md +47 -0
  24. streamgate-0.1.0/src/streamgate/ingest/admission/redis_existence.py → streamgate-0.2.0/examples/redis_admission/admission.py +487 -489
  25. {streamgate-0.1.0/src/streamgate/cache → streamgate-0.2.0/examples/redis_admission}/existence.py +226 -233
  26. streamgate-0.2.0/examples/redis_admission/models.py +10 -0
  27. streamgate-0.2.0/examples/redis_admission/produce.py +81 -0
  28. streamgate-0.2.0/examples/redis_admission/settings.py +18 -0
  29. streamgate-0.2.0/examples/sqlite_sink/README.md +48 -0
  30. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/backfill.py +6 -2
  31. {streamgate-0.1.0/src/streamgate/consumer → streamgate-0.2.0/examples/sqlite_sink}/classifier.py +56 -64
  32. streamgate-0.2.0/examples/sqlite_sink/config.py +50 -0
  33. streamgate-0.2.0/examples/sqlite_sink/consume.py +85 -0
  34. streamgate-0.2.0/examples/sqlite_sink/dialects/__init__.py +1 -0
  35. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/dialects/mssql.py +126 -126
  36. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/dialects/sqlite.py +57 -57
  37. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/engines.py +9 -22
  38. streamgate-0.2.0/examples/sqlite_sink/models.py +22 -0
  39. streamgate-0.2.0/examples/sqlite_sink/produce.py +58 -0
  40. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/upsert.py +61 -11
  41. {streamgate-0.1.0 → streamgate-0.2.0}/pyproject.toml +27 -33
  42. streamgate-0.2.0/src/streamgate/__init__.py +131 -0
  43. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/config.py +3 -66
  44. streamgate-0.2.0/src/streamgate/consumer/classifier.py +43 -0
  45. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/dlq.py +12 -13
  46. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/loop.py +54 -47
  47. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/runner.py +24 -46
  48. streamgate-0.2.0/src/streamgate/ingest/admission/in_memory.py +84 -0
  49. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/gateway.py +8 -43
  50. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/protocols.py +35 -1
  51. streamgate-0.2.0/src/streamgate/resilience/backpressure.py +43 -0
  52. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/specs.py +19 -62
  53. {streamgate-0.1.0 → streamgate-0.2.0}/uv.lock +6 -33
  54. streamgate-0.1.0/.import_linter_cache/5780e4798d429798e55d962d9844c38a9b5e0494.data.json +0 -1
  55. streamgate-0.1.0/.import_linter_cache/streamgate.meta.json +0 -1
  56. streamgate-0.1.0/PKG-INFO +0 -189
  57. streamgate-0.1.0/README.md +0 -146
  58. streamgate-0.1.0/src/streamgate/__init__.py +0 -214
  59. streamgate-0.1.0/src/streamgate/_optional.py +0 -38
  60. streamgate-0.1.0/src/streamgate/obs/__init__.py +0 -0
  61. streamgate-0.1.0/src/streamgate/resilience/__init__.py +0 -0
  62. streamgate-0.1.0/src/streamgate/transport/__init__.py +0 -0
  63. {streamgate-0.1.0 → streamgate-0.2.0}/.gitignore +0 -0
  64. {streamgate-0.1.0 → streamgate-0.2.0}/.import_linter_cache/.gitignore +0 -0
  65. {streamgate-0.1.0 → streamgate-0.2.0}/.import_linter_cache/CACHEDIR.TAG +0 -0
  66. {streamgate-0.1.0 → streamgate-0.2.0}/AGENTS.md +0 -0
  67. {streamgate-0.1.0 → streamgate-0.2.0}/LICENSE +0 -0
  68. {streamgate-0.1.0/src/streamgate/cache → streamgate-0.2.0/src/streamgate/consumer}/__init__.py +0 -0
  69. {streamgate-0.1.0/src/streamgate/consumer → streamgate-0.2.0/src/streamgate/ingest}/__init__.py +0 -0
  70. {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/src/streamgate/ingest/admission}/__init__.py +0 -0
  71. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/admission/no_admission.py +0 -0
  72. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/producer.py +0 -0
  73. {streamgate-0.1.0/src/streamgate/db/dialects → streamgate-0.2.0/src/streamgate/obs}/__init__.py +0 -0
  74. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/obs/logging.py +0 -0
  75. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/obs/metrics.py +0 -0
  76. {streamgate-0.1.0/src/streamgate/ingest → streamgate-0.2.0/src/streamgate/resilience}/__init__.py +0 -0
  77. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/resilience/health.py +0 -0
  78. {streamgate-0.1.0/src/streamgate/ingest/admission → streamgate-0.2.0/src/streamgate/transport}/__init__.py +0 -0
  79. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/transport/codec.py +0 -0
  80. {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/transport/kafka.py +0 -0
@@ -0,0 +1,133 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ checks:
10
+ name: checks (py${{ matrix.python-version }})
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install project and dev tools
22
+ run: uv sync
23
+ - name: Lint
24
+ run: uv run ruff check .
25
+ - name: Import contracts (layering + pure-core forbidden modules)
26
+ run: uv run lint-imports
27
+ - name: Type check
28
+ run: uv run pyright
29
+ - name: Build distributions
30
+ run: uv build
31
+
32
+ examples:
33
+ # Examples are the storefront: they must meet the same quality bar as the
34
+ # package (lint + typecheck). Dev group carries the third-party libs the
35
+ # example modules import (sqlalchemy/sqlmodel/redis/httpx/aioodbc).
36
+ name: examples (lint + typecheck)
37
+ runs-on: ubuntu-latest
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: astral-sh/setup-uv@v5
41
+ - name: Install project and dev tools
42
+ run: uv sync
43
+ - name: Lint examples
44
+ run: uv run ruff check examples
45
+ - name: Type check examples
46
+ run: uv run pyright examples
47
+
48
+ bare-install:
49
+ # Regression gate for the pure-core contract: a bare install
50
+ # (`pip install .`) must pull exactly aiokafka/loguru/pydantic (+transitive),
51
+ # import cleanly, and run the pure_pipeline example end to end
52
+ # (ingest → Kafka → consume → sqlite sink) against a real broker.
53
+ runs-on: ubuntu-latest
54
+ services:
55
+ kafka:
56
+ image: apache/kafka:3.9.0
57
+ env:
58
+ KAFKA_NODE_ID: 1
59
+ KAFKA_PROCESS_ROLES: broker,controller
60
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
61
+ KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,PLAINTEXT_HOST://:29092
62
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
63
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
64
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
65
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
66
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
67
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
68
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
69
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
70
+ ports:
71
+ - 29092:29092
72
+ options: >-
73
+ --health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
74
+ --health-interval 10s
75
+ --health-timeout 10s
76
+ --health-retries 12
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ - uses: astral-sh/setup-uv@v5
80
+ - name: Bare install (no extras — the extras concept no longer exists)
81
+ run: |
82
+ uv venv bare
83
+ uv pip install --python bare/bin/python .
84
+ - name: "Smoke: dependency tree is pure (no DB/redis/httpx carriers)"
85
+ run: |
86
+ bare/bin/python -m pip list --format=freeze > deps.txt
87
+ cat deps.txt
88
+ for pkg in sqlalchemy sqlmodel redis aiosqlite aioodbc httpx; do
89
+ if grep -qi "^${pkg}==" deps.txt; then
90
+ echo "pure-core violation: ${pkg} leaked into bare install"; exit 1
91
+ fi
92
+ done
93
+ - name: "Smoke: bare import + core contracts"
94
+ run: |
95
+ bare/bin/python - <<'EOF'
96
+ import streamgate
97
+
98
+ assert streamgate.__version__
99
+
100
+ from streamgate import (
101
+ ConsumeSpec, DefaultErrorClassifier, ErrorKind,
102
+ InMemoryAdmission, IngestBinding, IngestGateway,
103
+ ManualBackpressureSignal,
104
+ )
105
+
106
+ try:
107
+ ConsumeSpec()
108
+ except ValueError as e:
109
+ assert "sink" in str(e) and "on_record" in str(e)
110
+
111
+ print("bare-install import smoke OK")
112
+ EOF
113
+ - name: "Smoke: pure_pipeline end to end (produce → Kafka → consume → sqlite)"
114
+ working-directory: examples/pure_pipeline
115
+ run: |
116
+ KAFKA__BOOTSTRAP_SERVERS=localhost:29092 ../../../bare/bin/python consume.py &
117
+ CONSUMER_PID=$!
118
+ sleep 15
119
+ KAFKA__BOOTSTRAP_SERVERS=localhost:29092 ../../../bare/bin/python produce.py | tee produce.out
120
+ grep -q "o-1: accepted" produce.out
121
+ grep -q "o-1: conflict" produce.out # in-memory uniqueness contract
122
+ grep -q "o-2: accepted" produce.out
123
+ sleep 20 # allow batch-timeout flush + offset commit
124
+ kill -INT $CONSUMER_PID
125
+ wait $CONSUMER_PID
126
+ ../../../bare/bin/python - <<'EOF'
127
+ import sqlite3
128
+
129
+ con = sqlite3.connect("data/pipeline.db")
130
+ (count,) = con.execute("select count(*) from orders").fetchone()
131
+ assert count == 2, f"expected 2 orders in sqlite sink, got {count}"
132
+ print(f"pure_pipeline smoke OK: {count} orders landed in sqlite (o-1 deduplicated)")
133
+ EOF
@@ -0,0 +1,41 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI via Trusted Publisher (OIDC) — no API token stored in the repo.
4
+ #
5
+ # One-time manual setup (outside this repository):
6
+ # 1. Create the PyPI project "streamgate" owner account with 2FA enabled.
7
+ # 2. On PyPI: project -> Publishing -> add a "GitHub" Trusted Publisher:
8
+ # owner / repository = <owner>/streamgate
9
+ # workflow filename = release.yml
10
+ # environment name = pypi
11
+ # (create the protected GitHub environment "pypi" on the repo as well)
12
+ # 3. Rehearse on TestPyPI first by adding an equivalent Trusted Publisher
13
+ # for testpypi and temporarily retargeting the publish step below.
14
+ #
15
+ # Before tagging, ensure src/streamgate/__init__.py __version__ and CHANGELOG.md
16
+ # both mention the version being released (workflow fails fast on mismatch).
17
+
18
+ on:
19
+ push:
20
+ tags: ["v*"]
21
+
22
+ jobs:
23
+ publish:
24
+ runs-on: ubuntu-latest
25
+ environment: pypi
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: astral-sh/setup-uv@v5
31
+ - name: Verify tag matches package version
32
+ run: |
33
+ PKG_VERSION="$(grep -oP '(?<=__version__ = ")[^"]+' src/streamgate/__init__.py)"
34
+ if [ "v${PKG_VERSION}" != "${GITHUB_REF_NAME}" ]; then
35
+ echo "tag ${GITHUB_REF_NAME} does not match package version v${PKG_VERSION}"
36
+ exit 1
37
+ fi
38
+ - name: Build distributions
39
+ run: uv build
40
+ - name: Publish to PyPI
41
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1 @@
1
+ {"imports_by_module":{"streamgate":[["streamgate.consumer.dlq",false,26,"from streamgate.consumer.dlq import ("],["streamgate.consumer.runner",false,35,"from streamgate.consumer.runner import ConsumerWorker"],["streamgate.specs",false,72,"from streamgate.specs import ConsumeSpec, IngestBinding, IngestRecordT"],["streamgate.consumer.loop",false,34,"from streamgate.consumer.loop import ConsumeRuntime, consume_loop"],["streamgate.consumer.classifier",false,25,"from streamgate.consumer.classifier import DefaultErrorClassifier"],["streamgate.ingest.admission.no_admission",false,37,"from streamgate.ingest.admission.no_admission import NoAdmission"],["streamgate.ingest.producer",false,39,"from streamgate.ingest.producer import KafkaProducerService"],["streamgate.transport.codec",false,73,"from streamgate.transport.codec import JsonEnvelopeCodec"],["streamgate.resilience.health",false,66,"from streamgate.resilience.health import ("],["streamgate.obs.logging",false,40,"from streamgate.obs.logging import configure_logger, logger"],["streamgate.protocols",false,42,"from streamgate.protocols import ("],["streamgate.ingest.admission.in_memory",false,36,"from streamgate.ingest.admission.in_memory import InMemoryAdmission"],["streamgate.config",false,24,"from streamgate.config import BackpressureConfig, ConsumerConfig, KafkaConfig"],["streamgate.obs.metrics",false,41,"from streamgate.obs.metrics import LoggingMetricsSink, MetricsSink"],["streamgate.resilience.backpressure",false,65,"from streamgate.resilience.backpressure import ManualBackpressureSignal"],["streamgate.transport.kafka",false,74,"from streamgate.transport.kafka import KafkaConsumerService"],["streamgate.ingest.gateway",false,38,"from streamgate.ingest.gateway import IngestGateway"]],"streamgate.config":[],"streamgate.consumer":[],"streamgate.consumer.classifier":[["streamgate.protocols",false,20,"from streamgate.protocols import ErrorKind"]],"streamgate.consumer.dlq":[["streamgate.config",false,22,"from streamgate.config import KafkaConfig"],["streamgate.obs.logging",false,23,"from streamgate.obs.logging import logger"],["streamgate.protocols",false,24,"from streamgate.protocols import ErrorKind, JsonObject, RecordWriter"]],"streamgate.consumer.loop":[["streamgate.consumer.classifier",false,14,"from streamgate.consumer.classifier import DefaultErrorClassifier"],["streamgate.consumer.dlq",false,15,"from streamgate.consumer.dlq import ("],["streamgate.config",false,13,"from streamgate.config import ConsumerConfig"],["streamgate.obs.metrics",false,25,"from streamgate.obs.metrics import DEFAULT_METRICS, MetricsSink"],["streamgate.protocols",false,26,"from streamgate.protocols import ("],["streamgate.specs",false,36,"from streamgate.specs import ConsumeSpec"],["streamgate.transport.kafka",false,37,"from streamgate.transport.kafka import KafkaConsumerService, KafkaRecord"],["streamgate.obs.logging",false,24,"from streamgate.obs.logging import logger"]],"streamgate.consumer.runner":[["streamgate.protocols",false,15,"from streamgate.protocols import ErrorClassifier, MessageCodec"],["streamgate.config",false,11,"from streamgate.config import ConsumerConfig, KafkaConfig"],["streamgate.consumer.dlq",false,12,"from streamgate.consumer.dlq import DlqProducer"],["streamgate.specs",false,20,"from streamgate.specs import ConsumeSpec"],["streamgate.obs.logging",false,14,"from streamgate.obs.logging import logger"],["streamgate.resilience.health",false,16,"from streamgate.resilience.health import ("],["streamgate.transport.codec",false,21,"from streamgate.transport.codec import JsonEnvelopeCodec"],["streamgate.transport.kafka",false,22,"from streamgate.transport.kafka import KafkaConsumerService"],["streamgate.consumer.loop",false,13,"from streamgate.consumer.loop import ConsumeRuntime, consume_loop"]],"streamgate.ingest":[],"streamgate.ingest.admission":[],"streamgate.ingest.admission.in_memory":[["streamgate.protocols",false,15,"from streamgate.protocols import Decision, JsonObject, RecordT"]],"streamgate.ingest.admission.no_admission":[["streamgate.protocols",false,5,"from streamgate.protocols import Decision, RecordT"]],"streamgate.ingest.gateway":[["streamgate.config",false,11,"from streamgate.config import BackpressureConfig, KafkaConfig"],["streamgate.protocols",false,16,"from streamgate.protocols import ("],["streamgate.transport.codec",false,31,"from streamgate.transport.codec import JsonEnvelopeCodec"],["streamgate.ingest.producer",false,14,"from streamgate.ingest.producer import KafkaProducerService"],["streamgate.resilience.backpressure",false,25,"from streamgate.resilience.backpressure import ManualBackpressureSignal"],["streamgate.obs.logging",false,15,"from streamgate.obs.logging import logger"],["streamgate.ingest.admission.no_admission",false,13,"from streamgate.ingest.admission.no_admission import NoAdmission"],["streamgate.resilience.health",false,26,"from streamgate.resilience.health import ("],["streamgate.ingest.admission.in_memory",false,12,"from streamgate.ingest.admission.in_memory import InMemoryAdmission"],["streamgate.specs",false,30,"from streamgate.specs import IngestBinding, IngestRecordT"]],"streamgate.ingest.producer":[["streamgate.config",false,16,"from streamgate.config import KafkaConfig"],["streamgate.protocols",false,18,"from streamgate.protocols import JsonObject"],["streamgate.obs.logging",false,17,"from streamgate.obs.logging import logger"]],"streamgate.obs":[],"streamgate.obs.logging":[],"streamgate.obs.metrics":[["streamgate.obs.logging",false,10,"from streamgate.obs.logging import logger"]],"streamgate.protocols":[],"streamgate.resilience":[],"streamgate.resilience.backpressure":[["streamgate.protocols",false,10,"from streamgate.protocols import AllowAllSignal, BackpressureSnapshot"]],"streamgate.resilience.health":[["streamgate.obs.logging",false,15,"from streamgate.obs.logging import logger"]],"streamgate.specs":[["streamgate.protocols",false,16,"from streamgate.protocols import ("]],"streamgate.transport":[],"streamgate.transport.codec":[["streamgate.protocols",false,11,"from streamgate.protocols import Envelope, JsonObject"],["streamgate.obs.logging",false,10,"from streamgate.obs.logging import logger"]],"streamgate.transport.kafka":[["streamgate.obs.logging",false,11,"from streamgate.obs.logging import logger"],["streamgate.config",false,10,"from streamgate.config import ConsumerConfig, KafkaConfig"]]},"version":2}
@@ -0,0 +1 @@
1
+ {"imports_by_module":{"streamgate":[["streamgate.ingest.gateway",false,38,"from streamgate.ingest.gateway import IngestGateway"],["streamgate.ingest.admission.in_memory",false,36,"from streamgate.ingest.admission.in_memory import InMemoryAdmission"],["streamgate.consumer.classifier",false,25,"from streamgate.consumer.classifier import DefaultErrorClassifier"],["streamgate.consumer.loop",false,34,"from streamgate.consumer.loop import ConsumeRuntime, consume_loop"],["streamgate.consumer.dlq",false,26,"from streamgate.consumer.dlq import ("],["streamgate.specs",false,72,"from streamgate.specs import ConsumeSpec, IngestBinding, IngestRecordT"],["streamgate.config",false,24,"from streamgate.config import BackpressureConfig, ConsumerConfig, KafkaConfig"],["streamgate.resilience.backpressure",false,65,"from streamgate.resilience.backpressure import ManualBackpressureSignal"],["streamgate.resilience.health",false,66,"from streamgate.resilience.health import ("],["streamgate.protocols",false,42,"from streamgate.protocols import ("],["streamgate.ingest.producer",false,39,"from streamgate.ingest.producer import KafkaProducerService"],["streamgate.obs.metrics",false,41,"from streamgate.obs.metrics import LoggingMetricsSink, MetricsSink"],["streamgate.transport.kafka",false,74,"from streamgate.transport.kafka import KafkaConsumerService"],["streamgate.ingest.admission.no_admission",false,37,"from streamgate.ingest.admission.no_admission import NoAdmission"],["streamgate.obs.logging",false,40,"from streamgate.obs.logging import configure_logger, logger"],["streamgate.transport.codec",false,73,"from streamgate.transport.codec import JsonEnvelopeCodec"],["streamgate.consumer.runner",false,35,"from streamgate.consumer.runner import ConsumerWorker"]],"streamgate.config":[["pydantic",false,8,"from pydantic import BaseModel, field_validator"]],"streamgate.consumer":[],"streamgate.consumer.classifier":[["streamgate.protocols",false,20,"from streamgate.protocols import ErrorKind"],["asyncio",false,16,"import asyncio"],["aiokafka",false,18,"from aiokafka.errors import KafkaError"]],"streamgate.consumer.dlq":[["json",false,16,"import json"],["asyncio",false,15,"import asyncio"],["aiokafka",false,20,"from aiokafka import AIOKafkaProducer"],["streamgate.config",false,22,"from streamgate.config import KafkaConfig"],["streamgate.obs.logging",false,23,"from streamgate.obs.logging import logger"],["datetime",false,18,"from datetime import datetime, timezone"],["dataclasses",false,17,"from dataclasses import dataclass, field"],["streamgate.protocols",false,24,"from streamgate.protocols import ErrorKind, JsonObject, RecordWriter"]],"streamgate.consumer.loop":[["streamgate.specs",false,36,"from streamgate.specs import ConsumeSpec"],["datetime",false,8,"from datetime import datetime, timezone"],["streamgate.config",false,13,"from streamgate.config import ConsumerConfig"],["time",false,6,"import time"],["collections",false,7,"from collections.abc import Callable"],["asyncio",false,5,"import asyncio"],["streamgate.consumer.dlq",false,15,"from streamgate.consumer.dlq import ("],["aiokafka",false,10,"from aiokafka.errors import CommitFailedError"],["streamgate.obs.metrics",false,25,"from streamgate.obs.metrics import DEFAULT_METRICS, MetricsSink"],["streamgate.consumer.classifier",false,14,"from streamgate.consumer.classifier import DefaultErrorClassifier"],["aiokafka",false,11,"from aiokafka.structs import OffsetAndMetadata, TopicPartition"],["streamgate.obs.logging",false,24,"from streamgate.obs.logging import logger"],["streamgate.transport.kafka",false,37,"from streamgate.transport.kafka import KafkaConsumerService, KafkaRecord"],["streamgate.protocols",false,26,"from streamgate.protocols import ("]],"streamgate.consumer.runner":[["streamgate.consumer.loop",false,13,"from streamgate.consumer.loop import ConsumeRuntime, consume_loop"],["streamgate.consumer.dlq",false,12,"from streamgate.consumer.dlq import DlqProducer"],["streamgate.resilience.health",false,16,"from streamgate.resilience.health import ("],["asyncio",false,8,"import asyncio"],["streamgate.config",false,11,"from streamgate.config import ConsumerConfig, KafkaConfig"],["streamgate.obs.logging",false,14,"from streamgate.obs.logging import logger"],["signal",false,9,"import signal"],["streamgate.protocols",false,15,"from streamgate.protocols import ErrorClassifier, MessageCodec"],["streamgate.transport.kafka",false,22,"from streamgate.transport.kafka import KafkaConsumerService"],["streamgate.specs",false,20,"from streamgate.specs import ConsumeSpec"],["streamgate.transport.codec",false,21,"from streamgate.transport.codec import JsonEnvelopeCodec"]],"streamgate.ingest":[],"streamgate.ingest.admission":[],"streamgate.ingest.admission.in_memory":[["typing",false,13,"from typing import Generic"],["streamgate.protocols",false,15,"from streamgate.protocols import Decision, JsonObject, RecordT"],["collections",false,12,"from collections.abc import Callable"]],"streamgate.ingest.admission.no_admission":[["streamgate.protocols",false,5,"from streamgate.protocols import Decision, RecordT"],["typing",false,3,"from typing import Generic"]],"streamgate.ingest.gateway":[["streamgate.ingest.admission.in_memory",false,12,"from streamgate.ingest.admission.in_memory import InMemoryAdmission"],["streamgate.specs",false,30,"from streamgate.specs import IngestBinding, IngestRecordT"],["datetime",false,8,"from datetime import datetime, timezone"],["streamgate.resilience.health",false,26,"from streamgate.resilience.health import ("],["streamgate.resilience.backpressure",false,25,"from streamgate.resilience.backpressure import ManualBackpressureSignal"],["streamgate.ingest.producer",false,14,"from streamgate.ingest.producer import KafkaProducerService"],["streamgate.obs.logging",false,15,"from streamgate.obs.logging import logger"],["typing",false,9,"from typing import Generic"],["streamgate.config",false,11,"from streamgate.config import BackpressureConfig, KafkaConfig"],["streamgate.ingest.admission.no_admission",false,13,"from streamgate.ingest.admission.no_admission import NoAdmission"],["streamgate.transport.codec",false,31,"from streamgate.transport.codec import JsonEnvelopeCodec"],["streamgate.protocols",false,16,"from streamgate.protocols import ("]],"streamgate.ingest.producer":[["aiokafka",false,13,"from aiokafka import AIOKafkaProducer"],["datetime",false,11,"from datetime import datetime, timezone"],["aiokafka",false,14,"from aiokafka.errors import KafkaConnectionError, KafkaError"],["streamgate.protocols",false,18,"from streamgate.protocols import JsonObject"],["json",false,9,"import json"],["streamgate.obs.logging",false,17,"from streamgate.obs.logging import logger"],["streamgate.config",false,16,"from streamgate.config import KafkaConfig"],["time",false,10,"import time"],["asyncio",false,8,"import asyncio"]],"streamgate.obs":[],"streamgate.obs.logging":[["loguru",false,6,"from loguru._handler import Message"],["json",false,1,"import json"],["datetime",false,3,"from datetime import timezone"],["sys",false,2,"import sys"],["loguru",false,5,"from loguru import logger as _logger"]],"streamgate.obs.metrics":[["typing",false,8,"from typing import Protocol"],["streamgate.obs.logging",false,10,"from streamgate.obs.logging import logger"]],"streamgate.protocols":[["collections",false,7,"from collections.abc import Awaitable, Callable"],["datetime",false,9,"from datetime import datetime"],["enum",false,10,"from enum import Enum"],["typing",false,11,"from typing import Protocol, TypeVar, runtime_checkable"],["dataclasses",false,8,"from dataclasses import dataclass, field"]],"streamgate.resilience":[],"streamgate.resilience.backpressure":[["streamgate.protocols",false,10,"from streamgate.protocols import AllowAllSignal, BackpressureSnapshot"]],"streamgate.resilience.health":[["datetime",false,10,"from datetime import datetime, timezone"],["typing",false,11,"from typing import Protocol"],["time",false,8,"import time"],["pydantic",false,13,"from pydantic import BaseModel, Field"],["asyncio",false,7,"import asyncio"],["collections",false,9,"from collections.abc import Awaitable"],["streamgate.obs.logging",false,15,"from streamgate.obs.logging import logger"]],"streamgate.specs":[["dataclasses",false,11,"from dataclasses import dataclass, field"],["collections",false,10,"from collections.abc import Callable, Hashable"],["typing",false,12,"from typing import Generic, Literal, TypeVar"],["pydantic",false,14,"from pydantic import BaseModel"],["streamgate.protocols",false,16,"from streamgate.protocols import ("]],"streamgate.transport":[],"streamgate.transport.codec":[["json",false,8,"import json"],["streamgate.obs.logging",false,10,"from streamgate.obs.logging import logger"],["streamgate.protocols",false,11,"from streamgate.protocols import Envelope, JsonObject"]],"streamgate.transport.kafka":[["aiokafka",false,7,"from aiokafka.errors import CommitFailedError, KafkaConnectionError, KafkaError"],["aiokafka",false,8,"from aiokafka.structs import ConsumerRecord, OffsetAndMetadata, TopicPartition"],["asyncio",false,3,"import asyncio"],["aiokafka",false,5,"from aiokafka import AIOKafkaConsumer"],["aiokafka",false,6,"from aiokafka.consumer.group_coordinator import GroupCoordinator"],["streamgate.obs.logging",false,11,"from streamgate.obs.logging import logger"],["streamgate.config",false,10,"from streamgate.config import ConsumerConfig, KafkaConfig"]]},"version":2}
@@ -0,0 +1 @@
1
+ {"streamgate.ingest.producer": 1788776206.139223, "streamgate.resilience": 1788742270.5761602, "streamgate.resilience.backpressure": 1788778626.931601, "streamgate.obs.metrics": 1788749264.9746833, "streamgate.protocols": 1788780004.1189399, "streamgate.transport.codec": 1788749619.0487723, "streamgate.specs": 1788778652.1194282, "streamgate": 1788778876.7474682, "streamgate.consumer": 1788742270.5761602, "streamgate.resilience.health": 1788767769.120218, "streamgate.obs.logging": 1788749262.353655, "streamgate.consumer.classifier": 1788778605.4753034, "streamgate.transport.kafka": 1788776206.140225, "streamgate.consumer.runner": 1788778819.43872, "streamgate.ingest.gateway": 1788778851.3711455, "streamgate.ingest.admission": 1788742270.5761602, "streamgate.config": 1788778673.809221, "streamgate.obs": 1788742270.5761602, "streamgate.ingest.admission.no_admission": 1788759910.7492158, "streamgate.ingest": 1788742270.5761602, "streamgate.ingest.admission.in_memory": 1788778617.9172873, "streamgate.transport": 1788742270.5761602, "streamgate.consumer.dlq": 1788778795.1676013, "streamgate.consumer.loop": 1788778765.328234}
@@ -0,0 +1,116 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-09-07
9
+
10
+ **Pure-core release.** All database, Redis and HTTP-client code is removed from
11
+ the wheel; every I/O strategy is now a typed protocol hook with a copy-paste
12
+ reference implementation in `examples/`. Core dependencies collapse to
13
+ `aiokafka` + `loguru` + `pydantic`. This is a breaking release (0.1.0 was
14
+ published days earlier; the migration cost is intentionally accepted).
15
+
16
+ ### Changed
17
+
18
+ - **Core dependencies**: `aiokafka` / `loguru` / `pydantic` only. The
19
+ `sqlalchemy` and `sqlmodel` dependencies — and the entire extras system
20
+ (`[sqlite]`, `[mssql]`, `[redis]`, `[http-probe]`, `[all]`) — are gone.
21
+ - **`ConsumeSpec`**: landing targets reduced to two, mutually exclusive:
22
+ `sink` (a `RecordWriter` — the only persistence path) or `on_record`
23
+ (escape hatch). Providing neither — or both — fails fast at declaration
24
+ with fix guidance.
25
+ - **`IngestBinding.admission`**: string shortcuts are now `"none"` and
26
+ `"in-memory"` (new built-in, zero-I/O, single-process). `"redis-existence"`
27
+ is removed — inject a `RedisExistenceAdmission` instance instead
28
+ (copy from `examples/redis_admission/`).
29
+ - **`IngestGateway`**: default backpressure signal is now the built-in
30
+ `ManualBackpressureSignal` (static switch, defaults to not rejecting);
31
+ `db_config` / `redis_config` constructor parameters removed.
32
+ - **`ConsumerWorker`**: constructor parameters `db_config`, `redis_config`,
33
+ `writer`, `engine`, `session_factory` removed — the sink comes from
34
+ `ConsumeSpec.sink`. New `error_classifier` parameter (see below).
35
+
36
+ ### Added
37
+
38
+ - **`ErrorClassifier` protocol + `ErrorKind` enum** (`RETRY` / `POISON` /
39
+ `FATAL`): write-failure classification is now an injected policy.
40
+ `DefaultErrorClassifier` only recognizes generic exceptions
41
+ (timeouts / KafkaError → retry; ValueError → poison; unknown → poison,
42
+ probe-protected bisect). Consumers writing to a database **must** inject a
43
+ DB-aware classifier — `examples/sqlite_sink/classifier.py` ships a
44
+ production-hardened `SQLAlchemyErrorClassifier` (incl. SQL Server error
45
+ numbers).
46
+ - **`ManualBackpressureSignal`**: built-in pure in-memory backpressure switch.
47
+ - **`InMemoryAdmission`**: built-in zero-I/O uniqueness admission
48
+ (single-process only; multi-instance topologies should inject a
49
+ shared-storage policy).
50
+ - **`examples/`** (not part of the wheel, CI-gated for lint/typecheck):
51
+ - `pure_pipeline/` — bare-install full round trip (main example; the
52
+ living proof of `pip install streamgate` purity)
53
+ - `sqlite_sink/` — the former `db/` package (engines / upsert sugar /
54
+ backfill / SQLite+MSSQL dialects) as local modules + `RecordWriter`
55
+ injection + `SQLAlchemyErrorClassifier`
56
+ - `redis_admission/` — the former `cache/existence.py` +
57
+ `ingest/admission/redis_existence.py` + `AdmissionPolicy` injection
58
+ - `http_probe/` — the former `HttpProbeSignal` (hysteresis state machine) +
59
+ `BackpressureSignal` injection
60
+
61
+ ### Removed
62
+
63
+ - `streamgate.db` package (`UpsertWriter`, `SqlBackfill`, engine factories,
64
+ SQLite/MSSQL dialects), `Upsert` declaration, `DbConfig` / `RedisConfig`
65
+ (and their `DB__*` / `REDIS__*` env mappings), `RedisExistenceCache`,
66
+ `RedisExistenceAdmission` (+ related symbols), `HttpProbeSignal`,
67
+ `_optional` lazy-loading gate.
68
+ - `FailureCategory` / `add_failure_rule` / `classify_write_failure`
69
+ (superseded by `ErrorClassifier`).
70
+
71
+ ### Migration Guide (0.1.0 → 0.2.0)
72
+
73
+ | 0.1.0 symbol | Where it went |
74
+ |--------------|---------------|
75
+ | `Upsert` | `examples/sqlite_sink/upsert.py` (`Upsert`, local class) |
76
+ | `UpsertWriter` | `examples/sqlite_sink/upsert.py` — inject via `ConsumeSpec(sink=UpsertWriter(...))` |
77
+ | `DbConfig`, `create_read_engine`, `create_write_engine`, `async_session_factory` | `examples/sqlite_sink/config.py` + `engines.py` |
78
+ | `SqlBackfill` | `examples/sqlite_sink/backfill.py` |
79
+ | SQLite / MSSQL dialect internals | `examples/sqlite_sink/dialects/` |
80
+ | `RedisExistenceCache`, `EMPTY_FIELD` | `examples/redis_admission/existence.py` |
81
+ | `RedisExistenceAdmission`, `RedisExistenceAdmissionConfig`, `EntitySlots`, `SlotSource`, `ExistenceUnavailableError`, `UndeterminedReason` | `examples/redis_admission/admission.py` — inject via `IngestBinding(admission=RedisExistenceAdmission(...))` |
82
+ | `HttpProbeSignal` | `examples/http_probe/probe_signal.py` — inject via `IngestGateway(signal=HttpProbeSignal(...))` |
83
+ | `FailureCategory`, `classify_write_failure`, `add_failure_rule` | `ErrorKind` + `ErrorClassifier`; DB mapping in `examples/sqlite_sink/classifier.py` |
84
+ | `ConsumeSpec(upserts=[...])` | `ConsumeSpec(sink=UpsertWriter(..., [Upsert(...)]))` (writer built by you) |
85
+ | `ConsumerWorker(db_config=..., spec=ConsumeSpec(upserts=...))` | `ConsumerWorker(spec, ...)` with `ConsumeSpec(sink=...)`; pass `error_classifier=` |
86
+ | `admission="redis-existence"` | `admission=RedisExistenceAdmission(...)` (instance injection) |
87
+ | `pip install "streamgate[redis]"` etc. | `pip install streamgate <third-party-lib>` + copy the example module |
88
+
89
+ ## [0.1.0] - 2026-09-07
90
+
91
+ First public release. Data pipeline framework: conditional admission → reliable delivery → pluggable sinks.
92
+
93
+ ### Added
94
+
95
+ - **Ingest (admission)**
96
+ - `IngestGateway`: transport-agnostic ingest kernel — backpressure check → admission → Kafka send → on_accepted orchestration.
97
+ - `IngestBinding`: declarative ingest binding (Tier 0) with `admission="none" | "redis-existence"` shortcuts or a custom `AdmissionPolicy`.
98
+ - `RedisExistenceAdmission` / `RedisExistenceCache` (extras: `[redis]`): uniqueness contract via existence check + atomic slot reservation (cluster-safe Lua) + TTL idle-GC + empty-entity sentinels + fail-closed semantics; optional cold-entity DB backfill via `BackfillSource` (`SqlBackfill`).
99
+ - **Transport**
100
+ - `KafkaProducerService` / `KafkaConsumerService` over aiokafka: lazy connect, health probing, self-healing reconnects with exponential backoff and rebuild thresholds.
101
+ - `JsonEnvelopeCodec` envelope format; custom `MessageCodec` supported.
102
+ - **Consumer (sink)**
103
+ - `ConsumerWorker` + `consume_loop`: batch buffering, at-least-once processing, graceful shutdown.
104
+ - `ConsumeSpec` (Tier 0) with three mutually exclusive landing targets: `upserts` (built-in idempotent SQL upserts via `Upsert`), `sink` (`RecordWriter` injection point), `on_record` (no-sink escape hatch).
105
+ - Built-in SQLite / SQL Server upsert dialects (extras: `[sqlite]` / `[mssql]`); other databases via Tier 1 `RecordWriter`.
106
+ - DLQ bisection: poison records are located by batch bisection and quarantined to a dead-letter topic instead of wedging the partition.
107
+ - **Resilience & observability**
108
+ - Consumption backpressure with hysteresis (trip/recover on backlog age), fail-closed probing, `HttpProbeSignal` (extras: `[http-probe]`); custom `BackpressureSignal` supported.
109
+ - Health snapshots for both processes (`collect_ingest_health` / `collect_consumer_health`); exposure is left to the caller (the package contains no web-framework code).
110
+ - Structured logging via loguru (`configure_logger`, `logger`) and a pluggable `MetricsSink`.
111
+ - **Packaging**
112
+ - Mechanisms as core dependencies; all policy carriers (DB drivers, redis, httpx) as optional extras with lazy loading and install-guidance errors.
113
+ - Typed configuration objects mapping 1:1 to `KAFKA__*` / `CONSUMER__*` / `DB__*` / `REDIS__*` / `BACKPRESSURE__*` environment variables; required settings fail fast at startup.
114
+
115
+ [0.2.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.2.0
116
+ [0.1.0]: https://github.com/pwg-code/streamgate/releases/tag/v0.1.0
@@ -0,0 +1,79 @@
1
+ # Configuration reference
2
+
3
+ streamgate components are configured with typed Pydantic objects whose fields map
4
+ 1:1 to environment variables using a `PREFIX__field` convention (double
5
+ underscore):
6
+
7
+ | Prefix | Config object |
8
+ |--------|---------------|
9
+ | `KAFKA__` | `KafkaConfig` |
10
+ | `CONSUMER__` | `ConsumerConfig` |
11
+ | `BACKPRESSURE__` | `BackpressureConfig` |
12
+
13
+ **Fail-fast semantics:** required settings are not defaulted. If a required value
14
+ is missing, assembly fails at startup with an error that names the setting and
15
+ how to fix it — the process never runs with project-specific guessed defaults.
16
+
17
+ Database and Redis connection settings are **not part of the framework**:
18
+ storage carriers are injected via protocols and their configuration belongs to
19
+ your application (see the example-local configs in `examples/sqlite_sink/` and
20
+ `examples/redis_admission/`).
21
+
22
+ ---
23
+
24
+ ## KafkaConfig (`KAFKA__*`)
25
+
26
+ | Env var | Default | Description |
27
+ |---------|---------|-------------|
28
+ | `KAFKA__BOOTSTRAP_SERVERS` | `kafka:9092` | Comma-separated broker list. `http://` / `https://` / `kafka://` scheme prefixes are stripped automatically. |
29
+ | `KAFKA__TOPIC` | **required** | Default topic for producer and consumer. Missing ⇒ startup failure (producer/consumer assembly validates). |
30
+ | `KAFKA__DLQ_TOPIC` | `None` (required when DLQ enabled) | Dead-letter topic for quarantined poison records. Required when `dlq_enabled` is on and no per-spec override is set. |
31
+ | `KAFKA__ACKS` | `all` | Producer acks level. |
32
+ | `KAFKA__REQUEST_TIMEOUT_MS` | `10000` | Producer request timeout (ms). |
33
+ | `KAFKA__ENABLE_IDEMPOTENCE` | `true` | Idempotent producer (safe retries). |
34
+ | `KAFKA__HEALTH_CHECK_INTERVAL_SECONDS` | `30.0` | Periodic health probe interval; unhealthy instances are rebuilt. |
35
+ | `KAFKA__RECONNECT_BASE_BACKOFF_SECONDS` | `1.0` | Reconnect exponential backoff base. |
36
+ | `KAFKA__RECONNECT_MAX_BACKOFF_SECONDS` | `30.0` | Reconnect backoff cap. |
37
+ | `KAFKA__RECONNECT_FAILURE_THRESHOLD` | `1` | Consecutive send failures before a rebuild is triggered (raise to tolerate broker jitter). |
38
+ | `KAFKA__SEND_FAILURE_WINDOW_SECONDS` | `30.0` | A send failure within this window marks the instance unhealthy. |
39
+ | `KAFKA__UNHEALTHY_CHECK_INTERVAL_SECONDS` | `5.0` | High-frequency probe interval while unhealthy/rebuilding. |
40
+
41
+ ## ConsumerConfig (`CONSUMER__*`)
42
+
43
+ | Env var | Default | Description |
44
+ |---------|---------|-------------|
45
+ | `CONSUMER__GROUP_ID` | **required** | Kafka consumer group id. Missing ⇒ startup failure. |
46
+ | `CONSUMER__BATCH_SIZE` | `100` | Records buffered before a write attempt. |
47
+ | `CONSUMER__BATCH_TIMEOUT_SECONDS` | `5.0` | Max wait before a partially filled batch is flushed. |
48
+ | `CONSUMER__MAX_RETRIES` | `3` | Write retries per batch. |
49
+ | `CONSUMER__RETRY_BACKOFF_BASE` | `1.0` | Write retry backoff base (seconds). |
50
+ | `CONSUMER__RECONNECT_BASE_BACKOFF_SECONDS` | `1.0` | Consumer reconnect backoff base. |
51
+ | `CONSUMER__RECONNECT_MAX_BACKOFF_SECONDS` | `30.0` | Consumer reconnect backoff cap. |
52
+ | `CONSUMER__MAX_POLL_RECORDS` | `500` | Max records per poll. |
53
+ | `CONSUMER__SESSION_TIMEOUT_MS` | `30000` | Kafka session timeout. |
54
+ | `CONSUMER__MAX_POLL_INTERVAL_MS` | `300000` | Max time between polls before rebalance. |
55
+ | `CONSUMER__AUTO_OFFSET_RESET` | `earliest` | Offset reset policy. |
56
+ | `CONSUMER__BACKLOG_CHECK_INTERVAL_SECONDS` | `30.0` | Backlog age check period (feeds backpressure metrics). |
57
+ | `CONSUMER__DLQ_ENABLED` | `true` | DLQ master switch; `false` falls back to the legacy paused behavior (emergency escape hatch). |
58
+ | `CONSUMER__DLQ_SEND_RETRIES` | `3` | Total attempts per DLQ send (exhausting them pauses the batch). |
59
+
60
+ ## BackpressureConfig (`BACKPRESSURE__*`)
61
+
62
+ The gateway defaults to the built-in `ManualBackpressureSignal` (static
63
+ switch). For dynamic probing inject your own `BackpressureSignal` — the
64
+ HTTP-probe reference in `examples/http_probe/` reads this config object.
65
+
66
+ | Env var | Default | Description |
67
+ |---------|---------|-------------|
68
+ | `BACKPRESSURE__ENABLED` | `true` | Master switch; `false` = fully open (emergency rollback). |
69
+ | `BACKPRESSURE__CONSUMER_HEALTH_URL` | `http://localhost:9109/health` | Consumer health endpoint polled by the probe. |
70
+ | `BACKPRESSURE__CHECK_INTERVAL_SECONDS` | `30.0` | Poll period while OPEN. |
71
+ | `BACKPRESSURE__TIMEOUT_SECONDS` | `2.0` | Per-probe timeout. |
72
+ | `BACKPRESSURE__PROBE_RETRIES` | `3` | Retries per cycle (excluding the first attempt); `0` disables. |
73
+ | `BACKPRESSURE__PROBE_RETRY_INTERVAL_SECONDS` | `15.0` | Interval between retries. |
74
+ | `BACKPRESSURE__TRIP_SECONDS` | `9000.0` | Reject when backlog age exceeds this. |
75
+ | `BACKPRESSURE__RECOVER_SECONDS` | `7200.0` | Resume when backlog age falls below this (hysteresis anti-flapping). |
76
+ | `BACKPRESSURE__RETRY_AFTER_SECONDS` | `60` | `Retry-After` hint (seconds) while rejecting. |
77
+ | `BACKPRESSURE__FAIL_CLOSED_ON_UNREACHABLE` | `true` | Treat unreachable probe (retries exhausted) as backlog-exceeded. |
78
+ | `BACKPRESSURE__REJECT_ON_ANY_DEGRADED` | `false` | Legacy escape hatch: reject when any component is degraded. |
79
+ | `BACKPRESSURE__UNHEALTHY_CHECK_INTERVAL_SECONDS` | `5.0` | Poll period while REJECTING. |
@@ -0,0 +1,40 @@
1
+ # Contributing
2
+
3
+ Thanks for your interest in contributing to streamgate!
4
+
5
+ ## Development setup
6
+
7
+ This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
8
+
9
+ ```bash
10
+ git clone https://github.com/pwg-code/streamgate
11
+ cd streamgate
12
+ uv sync # creates .venv and installs the project (editable) + dev tools
13
+ ```
14
+
15
+ ## Local checks
16
+
17
+ CI runs the same gates; please run them locally before opening a PR:
18
+
19
+ ```bash
20
+ uv run ruff check . # lint (correctness + import sorting)
21
+ uv run lint-imports # architectural layering contract
22
+ uv run pyright # static type check
23
+ uv build # wheel + sdist build
24
+ ```
25
+
26
+ ## Scope and conventions
27
+
28
+ - **Mechanisms vs. policies.** The package owns mechanisms (admission orchestration, delivery, backpressure, self-healing). Policy choices (entity/slot semantics, schemas, storage carriers) belong to users. PRs should keep this boundary.
29
+ - **Pure-core gate.** The package must never import `sqlalchemy`, `sqlmodel`, `redis`, `aiosqlite`, `aioodbc` or `httpx` — enforced by the import-linter forbidden contract in `pyproject.toml` (`[tool.importlinter]`). I/O strategy implementations belong in `examples/` as copy-paste code. If you add a protocol extension point, add or update a runnable example for it in the same PR.
30
+ - **Examples are the storefront.** Code in `examples/` is held to the same quality bar as the package itself: CI runs lint and typecheck over it, and example READMEs must match reality. Rotting examples rot the project.
31
+ - **Architecture gate.** The layering contract in `pyproject.toml` (`[tool.importlinter]`) is enforced: higher layers may import lower layers, never the reverse. If your change legitimately needs a new edge, update the contract in the same PR and explain why.
32
+ - **Comments language.** Internal docstrings/comments are written in Chinese and kept that way. Docstrings on public API symbols (exported from `streamgate.__init__`) should be English-friendly.
33
+ - **No test suite yet.** The project currently ships without tests (top roadmap item). Until then, please include a minimal reproduction script (or a runnable snippet) with bug reports and behavioral PRs so reviewers can verify against real data.
34
+ - **Changelog.** User-visible changes must update `CHANGELOG.md`.
35
+
36
+ ## Submitting
37
+
38
+ 1. Fork / create a branch.
39
+ 2. Make the change, run the four local checks above.
40
+ 3. Open a pull request with a short description and (if applicable) the reproduction script.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.5
2
+ Name: streamgate
3
+ Version: 0.2.0
4
+ Summary: A pure-core Kafka data pipeline framework: conditional admission, reliable delivery, pluggable sinks via typed hooks.
5
+ Project-URL: Homepage, https://github.com/pwg-code/streamgate
6
+ Project-URL: Repository, https://github.com/pwg-code/streamgate
7
+ Project-URL: Changelog, https://github.com/pwg-code/streamgate/blob/main/CHANGELOG.md
8
+ Author: Wanguo Pang
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: backpressure,dlq,ingestion,kafka,pipeline
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: aiokafka>=0.10
24
+ Requires-Dist: loguru>=0.7
25
+ Requires-Dist: pydantic>=2.5
26
+ Description-Content-Type: text/markdown
27
+
28
+ # streamgate
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/streamgate.svg)](https://pypi.org/project/streamgate/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/streamgate.svg)](https://pypi.org/project/streamgate/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/pwg-code/streamgate/blob/main/LICENSE)
33
+ [![CI](https://github.com/pwg-code/streamgate/actions/workflows/ci.yml/badge.svg)](https://github.com/pwg-code/streamgate/actions/workflows/ci.yml)
34
+
35
+ English | [中文](README.zh-CN.md)
36
+
37
+ **streamgate** is a pure-core Kafka data pipeline framework: conditional admission at the door, reliable delivery through Kafka, and a single typed hook — `RecordWriter` — wherever you want the data to land.
38
+
39
+ The wheel installs exactly three dependencies (`aiokafka`, `loguru`, `pydantic`) and contains **zero database, Redis or HTTP-client code**. All I/O strategies ship as copy-paste examples in [`examples/`](examples/).
40
+
41
+ - 中文提示:本仓库对外文档以英文为主;内部代码注释保留中文。
42
+
43
+ ## Why streamgate
44
+
45
+ - **Mechanisms in the framework, policies in your hands.** The framework owns admission *orchestration*, backpressure, health probing, reconnection, offset handling, batch buffering and graceful shutdown. You own entity/slot semantics, schemas, and — critically — **the storage carriers**: your database, your Redis, your HTTP probe are injected via typed protocols, never imported by the core.
46
+ - **A uniqueness contract without a mandated store.** The admission flow (existence check → atomic slot reservation → cold-entity backfill → consumer-side authoritative refresh) is framework machinery. Whether the existence store is Redis, an in-process dict, or something else is your policy: implement `AdmissionPolicy`, or use the built-in zero-I/O shortcuts (`"none"`, `"in-memory"`). A production-grade Redis reference lives in [`examples/redis_admission/`](examples/redis_admission/).
47
+ - **One sink hook, any storage.** `ConsumeSpec(sink=...)` takes a `RecordWriter` — write to SQL, Elasticsearch, another service, a file; the framework handles batching, retries, error classification and DLQ bisection around it. No database is built in, on purpose.
48
+ - **Error classification as a hook.** Transient vs. poison vs. fatal is storage-specific knowledge. Inject an `ErrorClassifier`; the default only knows generic exceptions (and maps unknowns to poison — probe-protected bisect, never silent drops). A production-hardened SQLAlchemy classifier is in [`examples/sqlite_sink/`](examples/sqlite_sink/).
49
+ - **Backpressure with hysteresis.** Ingest probes consumer health on a side channel and rejects based on backlog age with separate trip/recover thresholds. The core ships zero-I/O signals (`ManualBackpressureSignal`); the HTTP-probe reference is in [`examples/http_probe/`](examples/http_probe/).
50
+ - **DLQ bisection instead of head-of-line blocking.** A poison message never wedges a partition: the failing batch is bisected with probe comparison, the bad record is quarantined to a dead-letter topic, the rest commits.
51
+
52
+ ## Quick Start
53
+
54
+ A complete ingest → Kafka → consume → SQLite round trip that runs on a **bare install** (stdlib sink, zero extra packages) — [`examples/pure_pipeline/`](examples/pure_pipeline/):
55
+
56
+ ```python
57
+ import asyncio
58
+ from pydantic import BaseModel
59
+
60
+ from streamgate import (
61
+ BackpressureConfig, ConsumeSpec, ConsumerConfig, ConsumerWorker,
62
+ IngestBinding, IngestGateway, KafkaConfig,
63
+ )
64
+
65
+ KAFKA = KafkaConfig(bootstrap_servers="localhost:29092", topic="orders")
66
+
67
+ class OrderIn(BaseModel): # ingress schema (policy lives with you)
68
+ order_id: str
69
+ amount: float
70
+
71
+ binding = IngestBinding(
72
+ message_type="order",
73
+ entity_key=lambda r: r.order_id,
74
+ slot_key=lambda r: "order",
75
+ summary=lambda r: {"amount": r.amount},
76
+ admission="in-memory", # built-in zero-I/O uniqueness (single process)
77
+ )
78
+
79
+ async def ingest_one(record: OrderIn) -> None:
80
+ gateway = IngestGateway(binding=binding, kafka_config=KAFKA,
81
+ backpressure_config=BackpressureConfig(enabled=False))
82
+ await gateway.start()
83
+ outcome = await gateway.process(record) # ACCEPTED / CONFLICT / BACKPRESSURE / ...
84
+ await gateway.close()
85
+ print(outcome.kind)
86
+
87
+ async def handle(records, context) -> None: # your storage, any library
88
+ ...
89
+
90
+ async def consume() -> None:
91
+ spec = ConsumeSpec(on_record=handle, expected_message_type="order")
92
+ worker = ConsumerWorker(spec, kafka_config=KAFKA,
93
+ consumer_config=ConsumerConfig(group_id="order-sink"))
94
+ await worker.run() # blocks until SIGINT/SIGTERM
95
+ ```
96
+
97
+ ## Core concepts
98
+
99
+ | Tier | What you use | When |
100
+ |------|--------------|------|
101
+ | **0 — Declarative** | `IngestBinding` (ingest side) + `ConsumeSpec` with a `sink` or `on_record` (sink side) | The common path: declare message type and uniqueness keys; inject your writer or handler |
102
+ | **1 — Component swap** | Protocols in `streamgate.protocols` + built-in zero-I/O implementations | Replace admission, backpressure signal, codec, error classifier with your own |
103
+ | **2 — Escape hatch** | `ConsumeSpec.on_record` / `ConsumeContext` | Handle records yourself; no sink machinery at all |
104
+
105
+ Key protocols (all in `streamgate.protocols`, the frozen contract):
106
+
107
+ - `AdmissionPolicy` — decide admit / 409-conflict / reject per record before it reaches Kafka (existence check + atomic reservation + cold backfill are *your* implementation against *your* store).
108
+ - `RecordWriter` — where consumed batches land (database, search index, another service...). The only persistence path.
109
+ - `BackpressureSignal` — tells the gateway whether to accept; built-ins are zero-I/O, dynamic probes are injected.
110
+ - `ErrorClassifier` — RETRY / POISON / FATAL mapping for write failures.
111
+ - `BackfillSource` — cold-entity load for admission policies that need one.
112
+
113
+ **Stability:** v0.x, experimental. The protocols module is the frozen contract; everything else may still shift.
114
+
115
+ ## Architecture
116
+
117
+ ```
118
+ your HTTP app (presentation is yours: auth/routing/OpenAPI)
119
+ │
120
+ ▼
121
+ IngestGateway ──► BackpressureSignal (injected: manual / HTTP probe)
122
+ │ AdmissionPolicy (injected: none / in-memory /
123
+ │ your Redis|DB-backed implementation)
124
+ ▼
125
+ Kafka ◄──────────────────────────────────────────────┐
126
+ │ │ DLQ ◄─ poisoned records
127
+ ▼ │ (bisect + probe compare)
128
+ ConsumerWorker ──► RecordWriter (your storage) ──────────┘
129
+ │ └─ ErrorClassifier (injected)
130
+ └─ health snapshot (expose it with your own web framework)
131
+ ```
132
+
133
+ The package contains **zero web-framework code** (no fastapi/uvicorn) and **zero DB/Redis code** — enforced by an import-linter forbidden contract in CI.
134
+
135
+ ## Installation
136
+
137
+ ```bash
138
+ pip install streamgate # aiokafka + loguru + pydantic. That's it.
139
+ ```
140
+
141
+ No extras. For storage/backpressure carriers, copy from `examples/` and add the
142
+ third-party library your implementation needs (e.g. `redis`, `sqlalchemy`,
143
+ `httpx`) to *your* project:
144
+
145
+ | Example | Demonstrates | Extra deps for the example |
146
+ |---------|--------------|----------------------------|
147
+ | [`pure_pipeline/`](examples/pure_pipeline/) (main) | bare-install full round trip, `in-memory` admission, `on_record` stdlib SQLite sink | none |
148
+ | [`sqlite_sink/`](examples/sqlite_sink/) | `RecordWriter` injection with the SQL upsert sugar + DB error classifier | `sqlalchemy`, `sqlmodel`, `aiosqlite` |
149
+ | [`redis_admission/`](examples/redis_admission/) | `AdmissionPolicy` injection with Redis existence cache | `redis` |
150
+ | [`http_probe/`](examples/http_probe/) | `BackpressureSignal` injection with hysteresis | `httpx` |
151
+
152
+ Other databases (PostgreSQL, MySQL, ...): implement `RecordWriter` (Tier 1) — the same hook every built path uses.
153
+
154
+ ## Configuration
155
+
156
+ All components configure via typed objects that map 1:1 to environment variables (`KAFKA__*`, `CONSUMER__*`, `BACKPRESSURE__*`). Required settings (e.g. the Kafka topic, consumer group id) fail fast at startup with fix instructions. Database/Redis connection settings belong to your application (see the examples' local configs). See [CONFIGURATION.md](CONFIGURATION.md).
157
+
158
+ ## Examples
159
+
160
+ See [`examples/`](examples/) for four runnable pipelines plus a `docker-compose.yml` (Kafka + Redis). Start with [`examples/pure_pipeline/`](examples/pure_pipeline/) — it runs with nothing but `pip install streamgate`.
161
+
162
+ ## Roadmap
163
+
164
+ - Test suite (first release ships without tests; APIs are exercised in production but the project considers this its top debt)
165
+ - Documentation site
166
+ - More admission/sink reference implementations in `examples/`
167
+
168
+ ## License
169
+
170
+ [MIT](LICENSE)