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.
- streamgate-0.2.0/.github/workflows/ci.yml +133 -0
- streamgate-0.2.0/.github/workflows/release.yml +41 -0
- streamgate-0.2.0/.import_linter_cache/5780e4798d429798e55d962d9844c38a9b5e0494.data.json +1 -0
- streamgate-0.2.0/.import_linter_cache/a1215eee6d5c7b9f11de3767d6965c4a9c703b3e.data.json +1 -0
- streamgate-0.2.0/.import_linter_cache/streamgate.meta.json +1 -0
- streamgate-0.2.0/CHANGELOG.md +116 -0
- streamgate-0.2.0/CONFIGURATION.md +79 -0
- streamgate-0.2.0/CONTRIBUTING.md +40 -0
- streamgate-0.2.0/PKG-INFO +170 -0
- streamgate-0.2.0/README.md +143 -0
- streamgate-0.2.0/README.zh-CN.md +284 -0
- streamgate-0.2.0/examples/docker-compose.yml +35 -0
- streamgate-0.2.0/examples/http_probe/README.md +45 -0
- streamgate-0.2.0/examples/http_probe/consume.py +53 -0
- streamgate-0.2.0/examples/http_probe/health_server.py +46 -0
- streamgate-0.2.0/examples/http_probe/models.py +10 -0
- streamgate-0.1.0/src/streamgate/resilience/backpressure.py → streamgate-0.2.0/examples/http_probe/probe_signal.py +358 -370
- streamgate-0.2.0/examples/http_probe/produce.py +80 -0
- streamgate-0.2.0/examples/pure_pipeline/README.md +59 -0
- streamgate-0.2.0/examples/pure_pipeline/consume.py +80 -0
- streamgate-0.2.0/examples/pure_pipeline/models.py +14 -0
- streamgate-0.2.0/examples/pure_pipeline/produce.py +69 -0
- streamgate-0.2.0/examples/redis_admission/README.md +47 -0
- streamgate-0.1.0/src/streamgate/ingest/admission/redis_existence.py → streamgate-0.2.0/examples/redis_admission/admission.py +487 -489
- {streamgate-0.1.0/src/streamgate/cache → streamgate-0.2.0/examples/redis_admission}/existence.py +226 -233
- streamgate-0.2.0/examples/redis_admission/models.py +10 -0
- streamgate-0.2.0/examples/redis_admission/produce.py +81 -0
- streamgate-0.2.0/examples/redis_admission/settings.py +18 -0
- streamgate-0.2.0/examples/sqlite_sink/README.md +48 -0
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/backfill.py +6 -2
- {streamgate-0.1.0/src/streamgate/consumer → streamgate-0.2.0/examples/sqlite_sink}/classifier.py +56 -64
- streamgate-0.2.0/examples/sqlite_sink/config.py +50 -0
- streamgate-0.2.0/examples/sqlite_sink/consume.py +85 -0
- streamgate-0.2.0/examples/sqlite_sink/dialects/__init__.py +1 -0
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/dialects/mssql.py +126 -126
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/dialects/sqlite.py +57 -57
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/engines.py +9 -22
- streamgate-0.2.0/examples/sqlite_sink/models.py +22 -0
- streamgate-0.2.0/examples/sqlite_sink/produce.py +58 -0
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/examples/sqlite_sink}/upsert.py +61 -11
- {streamgate-0.1.0 → streamgate-0.2.0}/pyproject.toml +27 -33
- streamgate-0.2.0/src/streamgate/__init__.py +131 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/config.py +3 -66
- streamgate-0.2.0/src/streamgate/consumer/classifier.py +43 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/dlq.py +12 -13
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/loop.py +54 -47
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/consumer/runner.py +24 -46
- streamgate-0.2.0/src/streamgate/ingest/admission/in_memory.py +84 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/gateway.py +8 -43
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/protocols.py +35 -1
- streamgate-0.2.0/src/streamgate/resilience/backpressure.py +43 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/specs.py +19 -62
- {streamgate-0.1.0 → streamgate-0.2.0}/uv.lock +6 -33
- streamgate-0.1.0/.import_linter_cache/5780e4798d429798e55d962d9844c38a9b5e0494.data.json +0 -1
- streamgate-0.1.0/.import_linter_cache/streamgate.meta.json +0 -1
- streamgate-0.1.0/PKG-INFO +0 -189
- streamgate-0.1.0/README.md +0 -146
- streamgate-0.1.0/src/streamgate/__init__.py +0 -214
- streamgate-0.1.0/src/streamgate/_optional.py +0 -38
- streamgate-0.1.0/src/streamgate/obs/__init__.py +0 -0
- streamgate-0.1.0/src/streamgate/resilience/__init__.py +0 -0
- streamgate-0.1.0/src/streamgate/transport/__init__.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/.gitignore +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/.import_linter_cache/.gitignore +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/.import_linter_cache/CACHEDIR.TAG +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/AGENTS.md +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/LICENSE +0 -0
- {streamgate-0.1.0/src/streamgate/cache → streamgate-0.2.0/src/streamgate/consumer}/__init__.py +0 -0
- {streamgate-0.1.0/src/streamgate/consumer → streamgate-0.2.0/src/streamgate/ingest}/__init__.py +0 -0
- {streamgate-0.1.0/src/streamgate/db → streamgate-0.2.0/src/streamgate/ingest/admission}/__init__.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/admission/no_admission.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/ingest/producer.py +0 -0
- {streamgate-0.1.0/src/streamgate/db/dialects → streamgate-0.2.0/src/streamgate/obs}/__init__.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/obs/logging.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/obs/metrics.py +0 -0
- {streamgate-0.1.0/src/streamgate/ingest → streamgate-0.2.0/src/streamgate/resilience}/__init__.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/resilience/health.py +0 -0
- {streamgate-0.1.0/src/streamgate/ingest/admission → streamgate-0.2.0/src/streamgate/transport}/__init__.py +0 -0
- {streamgate-0.1.0 → streamgate-0.2.0}/src/streamgate/transport/codec.py +0 -0
- {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
|
+
[](https://pypi.org/project/streamgate/)
|
|
31
|
+
[](https://pypi.org/project/streamgate/)
|
|
32
|
+
[](https://github.com/pwg-code/streamgate/blob/main/LICENSE)
|
|
33
|
+
[](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)
|