tkati-node-el 0.3.0a3__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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: tkati-node-el
3
+ Version: 0.3.0a3
4
+ Summary: Generic extract/load node: reads from a configurable input and writes to a configurable output
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tkati-core==0.3.0-alpha.3
8
+ Requires-Dist: loguru>=0.7.0
9
+ Requires-Dist: pydantic-settings>=2.11.0
10
+
11
+ # tkati-node-el — generic extract/load node
12
+
13
+ Reads batches from a configurable input and writes them to a configurable output. Offsets are committed only after a successful write (at-least-once delivery).
14
+
15
+ Input and output kinds are selected via the `type` field in each section — pick from whatever `tkati-core` supports. Every backend's settings split a **`connection`** tier (server-specific: how to reach the broker/database) from the resource tier (`topic` for Kafka, `table` for ClickHouse) and, where relevant, a tier local to this reader/writer instance (Kafka's `consumer` settings).
16
+
17
+ - **Input**: `"kafka"` (JSON or Arrow-batch messages from a Kafka/Redpanda topic)
18
+ - **Output**: `"kafka"` or `"clickhouse"` (native Arrow insert)
19
+ - **DLQ**: same `OutputSettings` shape as `output` — a DLQ can be Kafka or ClickHouse too
20
+
21
+ ## Configuration
22
+
23
+ Settings are loaded from a TOML file. Set the `SETTINGS_FILE` environment variable to point to it (defaults to `settings.toml`).
24
+
25
+ ```toml
26
+ [input]
27
+ type = "kafka"
28
+
29
+ [input.connection]
30
+ broker = "redpanda:29092"
31
+
32
+ [input.topic]
33
+ name = "traffic_event"
34
+
35
+ [input.topic.schema]
36
+ uid = "string"
37
+ time = "timestamp[ms]"
38
+ traffic_in = "uint32"
39
+ # … other columns
40
+
41
+ [input.consumer]
42
+ group_id = "node-el-group"
43
+ batch_size = 1000
44
+ batch_timeout_sec = 10
45
+ auto_offset_reset = "latest"
46
+
47
+ [output]
48
+ type = "clickhouse"
49
+ dlq_split_factor = 10
50
+
51
+ [output.connection]
52
+ host = "clickhouse"
53
+ port = 9000
54
+ user = "default"
55
+ password = ""
56
+ secure = false
57
+
58
+ [output.table]
59
+ database = "default"
60
+ name = "traffic_event"
61
+
62
+ [dlq]
63
+ type = "kafka"
64
+
65
+ [dlq.connection]
66
+ broker = "redpanda:29092"
67
+
68
+ [dlq.topic]
69
+ name = "node-el-dlq"
70
+ ```
71
+
72
+ A Kafka output instead looks like:
73
+
74
+ ```toml
75
+ [output]
76
+ type = "kafka"
77
+
78
+ [output.connection]
79
+ broker = "redpanda:29092"
80
+
81
+ [output.topic]
82
+ name = "some-other-topic"
83
+ format = "json" # or "arrow-batch"
84
+ key_column = "uid" # optional
85
+ ```
86
+
87
+ A ClickHouse DLQ instead looks like:
88
+
89
+ ```toml
90
+ [dlq]
91
+ type = "clickhouse"
92
+
93
+ [dlq.connection]
94
+ host = "clickhouse"
95
+ port = 9000
96
+ user = "default"
97
+ password = ""
98
+ secure = false
99
+
100
+ [dlq.table]
101
+ database = "default"
102
+ name = "traffic_event_dlq"
103
+ ```
104
+
105
+ ## DLQ semantics
106
+
107
+ DLQ *fallback triggering* is currently only implemented for the `clickhouse` output kind — `KafkaProducer` has no retry/split logic of its own. The DLQ *sink* itself (where isolated bad rows end up) can be Kafka or ClickHouse, independent of the primary output. When a batch insert fails after all retries, the app switches to a recursive fallback to isolate the problematic rows:
108
+
109
+ 1. The failing batch is split into `dlq_split_factor` equal sub-batches and each is retried independently.
110
+ 2. If a sub-batch also fails it is split again — this repeats until individual rows are reached.
111
+ 3. A single row that ClickHouse still rejects is written to the DLQ sink, preserving the full schema (Arrow IPC `arrow-batch` format for a Kafka DLQ).
112
+ 4. After all rows are handled (inserted or DLQ'd), the input offset is committed and the app resumes normal large-batch processing.
113
+
114
+ `dlq_split_factor` is a setting on the `clickhouse` `[output]` block (see above), not on `[dlq]` — it describes how the primary output retries, independent of where the DLQ sink sends isolated rows. With `dlq_split_factor=10` and a 1 000-row batch this takes at most 3 recursive levels (1000 → 100 → 10 → 1).
115
+
116
+ **Delivery guarantee: at-least-once.** If the process crashes mid-recursion the uncommitted batch is re-read on restart and re-processed from the beginning, which may produce duplicate rows in the output and duplicate messages in the DLQ.
@@ -0,0 +1,106 @@
1
+ # tkati-node-el — generic extract/load node
2
+
3
+ Reads batches from a configurable input and writes them to a configurable output. Offsets are committed only after a successful write (at-least-once delivery).
4
+
5
+ Input and output kinds are selected via the `type` field in each section — pick from whatever `tkati-core` supports. Every backend's settings split a **`connection`** tier (server-specific: how to reach the broker/database) from the resource tier (`topic` for Kafka, `table` for ClickHouse) and, where relevant, a tier local to this reader/writer instance (Kafka's `consumer` settings).
6
+
7
+ - **Input**: `"kafka"` (JSON or Arrow-batch messages from a Kafka/Redpanda topic)
8
+ - **Output**: `"kafka"` or `"clickhouse"` (native Arrow insert)
9
+ - **DLQ**: same `OutputSettings` shape as `output` — a DLQ can be Kafka or ClickHouse too
10
+
11
+ ## Configuration
12
+
13
+ Settings are loaded from a TOML file. Set the `SETTINGS_FILE` environment variable to point to it (defaults to `settings.toml`).
14
+
15
+ ```toml
16
+ [input]
17
+ type = "kafka"
18
+
19
+ [input.connection]
20
+ broker = "redpanda:29092"
21
+
22
+ [input.topic]
23
+ name = "traffic_event"
24
+
25
+ [input.topic.schema]
26
+ uid = "string"
27
+ time = "timestamp[ms]"
28
+ traffic_in = "uint32"
29
+ # … other columns
30
+
31
+ [input.consumer]
32
+ group_id = "node-el-group"
33
+ batch_size = 1000
34
+ batch_timeout_sec = 10
35
+ auto_offset_reset = "latest"
36
+
37
+ [output]
38
+ type = "clickhouse"
39
+ dlq_split_factor = 10
40
+
41
+ [output.connection]
42
+ host = "clickhouse"
43
+ port = 9000
44
+ user = "default"
45
+ password = ""
46
+ secure = false
47
+
48
+ [output.table]
49
+ database = "default"
50
+ name = "traffic_event"
51
+
52
+ [dlq]
53
+ type = "kafka"
54
+
55
+ [dlq.connection]
56
+ broker = "redpanda:29092"
57
+
58
+ [dlq.topic]
59
+ name = "node-el-dlq"
60
+ ```
61
+
62
+ A Kafka output instead looks like:
63
+
64
+ ```toml
65
+ [output]
66
+ type = "kafka"
67
+
68
+ [output.connection]
69
+ broker = "redpanda:29092"
70
+
71
+ [output.topic]
72
+ name = "some-other-topic"
73
+ format = "json" # or "arrow-batch"
74
+ key_column = "uid" # optional
75
+ ```
76
+
77
+ A ClickHouse DLQ instead looks like:
78
+
79
+ ```toml
80
+ [dlq]
81
+ type = "clickhouse"
82
+
83
+ [dlq.connection]
84
+ host = "clickhouse"
85
+ port = 9000
86
+ user = "default"
87
+ password = ""
88
+ secure = false
89
+
90
+ [dlq.table]
91
+ database = "default"
92
+ name = "traffic_event_dlq"
93
+ ```
94
+
95
+ ## DLQ semantics
96
+
97
+ DLQ *fallback triggering* is currently only implemented for the `clickhouse` output kind — `KafkaProducer` has no retry/split logic of its own. The DLQ *sink* itself (where isolated bad rows end up) can be Kafka or ClickHouse, independent of the primary output. When a batch insert fails after all retries, the app switches to a recursive fallback to isolate the problematic rows:
98
+
99
+ 1. The failing batch is split into `dlq_split_factor` equal sub-batches and each is retried independently.
100
+ 2. If a sub-batch also fails it is split again — this repeats until individual rows are reached.
101
+ 3. A single row that ClickHouse still rejects is written to the DLQ sink, preserving the full schema (Arrow IPC `arrow-batch` format for a Kafka DLQ).
102
+ 4. After all rows are handled (inserted or DLQ'd), the input offset is committed and the app resumes normal large-batch processing.
103
+
104
+ `dlq_split_factor` is a setting on the `clickhouse` `[output]` block (see above), not on `[dlq]` — it describes how the primary output retries, independent of where the DLQ sink sends isolated rows. With `dlq_split_factor=10` and a 1 000-row batch this takes at most 3 recursive levels (1000 → 100 → 10 → 1).
105
+
106
+ **Delivery guarantee: at-least-once.** If the process crashes mid-recursion the uncommitted batch is re-read on restart and re-processed from the beginning, which may produce duplicate rows in the output and duplicate messages in the DLQ.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "tkati-node-el"
3
+ version = "0.3.0-alpha.3"
4
+ description = "Generic extract/load node: reads from a configurable input and writes to a configurable output"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "tkati-core==0.3.0-alpha.3",
9
+ "loguru>=0.7.0",
10
+ "pydantic-settings>=2.11.0",
11
+ ]
12
+
13
+ [project.scripts]
14
+ tkati-node-el = "tkati_node_el.main:main"
15
+
16
+ [dependency-groups]
17
+ dev = ["pytest>=9.0.1", "confluent-kafka>=2.11.0", "clickhouse-connect>=0.11.0"]
18
+
19
+ [build-system]
20
+ requires = ["setuptools", "wheel"]
21
+ build-backend = "setuptools.build_meta"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+ include = ["tkati_node_el*"]
26
+
27
+ [tool.uv]
28
+ package = true
29
+
30
+ [tool.uv-workspace-codegen]
31
+ generate = true
32
+ template_type = ["test", "publish"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,40 @@
1
+ from loguru import logger
2
+ from tkati_core import Consumer, Producer, build_consumer, build_producer
3
+
4
+ from tkati_node_el.settings import AppSettings
5
+
6
+
7
+ def run_one_iteration(
8
+ consumer: Consumer,
9
+ producer: Producer,
10
+ settings: AppSettings,
11
+ ) -> None:
12
+ batch = consumer.read_arrow(
13
+ num_messages=settings.input.consumer.batch_size,
14
+ timeout=settings.input.consumer.batch_timeout_sec,
15
+ )
16
+ if batch is None:
17
+ return
18
+ producer.produce_arrow(batch)
19
+ consumer.commit()
20
+ logger.info(f"Produced {len(batch)} rows")
21
+
22
+
23
+ def main() -> None:
24
+ settings = AppSettings() # type: ignore
25
+
26
+ consumer = build_consumer(settings.input)
27
+
28
+ dlq_producer: Producer | None = None
29
+ if settings.dlq is not None:
30
+ dlq_producer = build_producer(settings.dlq)
31
+
32
+ producer = build_producer(settings.output, dlq_producer=dlq_producer)
33
+
34
+ try:
35
+ while True:
36
+ run_one_iteration(consumer, producer, settings)
37
+ finally:
38
+ consumer.close()
39
+ if dlq_producer is not None:
40
+ dlq_producer.close()
File without changes
@@ -0,0 +1,8 @@
1
+ from tkati_core.settings import TomlBaseSettings
2
+ from tkati_core.settings import InputSettings, OutputSettings
3
+
4
+
5
+ class AppSettings(TomlBaseSettings):
6
+ input: InputSettings
7
+ output: OutputSettings
8
+ dlq: OutputSettings | None = None
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: tkati-node-el
3
+ Version: 0.3.0a3
4
+ Summary: Generic extract/load node: reads from a configurable input and writes to a configurable output
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tkati-core==0.3.0-alpha.3
8
+ Requires-Dist: loguru>=0.7.0
9
+ Requires-Dist: pydantic-settings>=2.11.0
10
+
11
+ # tkati-node-el — generic extract/load node
12
+
13
+ Reads batches from a configurable input and writes them to a configurable output. Offsets are committed only after a successful write (at-least-once delivery).
14
+
15
+ Input and output kinds are selected via the `type` field in each section — pick from whatever `tkati-core` supports. Every backend's settings split a **`connection`** tier (server-specific: how to reach the broker/database) from the resource tier (`topic` for Kafka, `table` for ClickHouse) and, where relevant, a tier local to this reader/writer instance (Kafka's `consumer` settings).
16
+
17
+ - **Input**: `"kafka"` (JSON or Arrow-batch messages from a Kafka/Redpanda topic)
18
+ - **Output**: `"kafka"` or `"clickhouse"` (native Arrow insert)
19
+ - **DLQ**: same `OutputSettings` shape as `output` — a DLQ can be Kafka or ClickHouse too
20
+
21
+ ## Configuration
22
+
23
+ Settings are loaded from a TOML file. Set the `SETTINGS_FILE` environment variable to point to it (defaults to `settings.toml`).
24
+
25
+ ```toml
26
+ [input]
27
+ type = "kafka"
28
+
29
+ [input.connection]
30
+ broker = "redpanda:29092"
31
+
32
+ [input.topic]
33
+ name = "traffic_event"
34
+
35
+ [input.topic.schema]
36
+ uid = "string"
37
+ time = "timestamp[ms]"
38
+ traffic_in = "uint32"
39
+ # … other columns
40
+
41
+ [input.consumer]
42
+ group_id = "node-el-group"
43
+ batch_size = 1000
44
+ batch_timeout_sec = 10
45
+ auto_offset_reset = "latest"
46
+
47
+ [output]
48
+ type = "clickhouse"
49
+ dlq_split_factor = 10
50
+
51
+ [output.connection]
52
+ host = "clickhouse"
53
+ port = 9000
54
+ user = "default"
55
+ password = ""
56
+ secure = false
57
+
58
+ [output.table]
59
+ database = "default"
60
+ name = "traffic_event"
61
+
62
+ [dlq]
63
+ type = "kafka"
64
+
65
+ [dlq.connection]
66
+ broker = "redpanda:29092"
67
+
68
+ [dlq.topic]
69
+ name = "node-el-dlq"
70
+ ```
71
+
72
+ A Kafka output instead looks like:
73
+
74
+ ```toml
75
+ [output]
76
+ type = "kafka"
77
+
78
+ [output.connection]
79
+ broker = "redpanda:29092"
80
+
81
+ [output.topic]
82
+ name = "some-other-topic"
83
+ format = "json" # or "arrow-batch"
84
+ key_column = "uid" # optional
85
+ ```
86
+
87
+ A ClickHouse DLQ instead looks like:
88
+
89
+ ```toml
90
+ [dlq]
91
+ type = "clickhouse"
92
+
93
+ [dlq.connection]
94
+ host = "clickhouse"
95
+ port = 9000
96
+ user = "default"
97
+ password = ""
98
+ secure = false
99
+
100
+ [dlq.table]
101
+ database = "default"
102
+ name = "traffic_event_dlq"
103
+ ```
104
+
105
+ ## DLQ semantics
106
+
107
+ DLQ *fallback triggering* is currently only implemented for the `clickhouse` output kind — `KafkaProducer` has no retry/split logic of its own. The DLQ *sink* itself (where isolated bad rows end up) can be Kafka or ClickHouse, independent of the primary output. When a batch insert fails after all retries, the app switches to a recursive fallback to isolate the problematic rows:
108
+
109
+ 1. The failing batch is split into `dlq_split_factor` equal sub-batches and each is retried independently.
110
+ 2. If a sub-batch also fails it is split again — this repeats until individual rows are reached.
111
+ 3. A single row that ClickHouse still rejects is written to the DLQ sink, preserving the full schema (Arrow IPC `arrow-batch` format for a Kafka DLQ).
112
+ 4. After all rows are handled (inserted or DLQ'd), the input offset is committed and the app resumes normal large-batch processing.
113
+
114
+ `dlq_split_factor` is a setting on the `clickhouse` `[output]` block (see above), not on `[dlq]` — it describes how the primary output retries, independent of where the DLQ sink sends isolated rows. With `dlq_split_factor=10` and a 1 000-row batch this takes at most 3 recursive levels (1000 → 100 → 10 → 1).
115
+
116
+ **Delivery guarantee: at-least-once.** If the process crashes mid-recursion the uncommitted batch is re-read on restart and re-processed from the beginning, which may produce duplicate rows in the output and duplicate messages in the DLQ.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/tkati_node_el/__init__.py
4
+ src/tkati_node_el/__main__.py
5
+ src/tkati_node_el/main.py
6
+ src/tkati_node_el/py.typed
7
+ src/tkati_node_el/settings.py
8
+ src/tkati_node_el.egg-info/PKG-INFO
9
+ src/tkati_node_el.egg-info/SOURCES.txt
10
+ src/tkati_node_el.egg-info/dependency_links.txt
11
+ src/tkati_node_el.egg-info/entry_points.txt
12
+ src/tkati_node_el.egg-info/requires.txt
13
+ src/tkati_node_el.egg-info/top_level.txt
14
+ tests/test_node_el.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tkati-node-el = tkati_node_el.main:main
@@ -0,0 +1,3 @@
1
+ tkati-core==0.3.0-alpha.3
2
+ loguru>=0.7.0
3
+ pydantic-settings>=2.11.0
@@ -0,0 +1 @@
1
+ tkati_node_el
@@ -0,0 +1,103 @@
1
+ import time
2
+ from unittest.mock import MagicMock
3
+
4
+ import clickhouse_connect.driver as ch_driver
5
+ import orjson
6
+ import pytest
7
+ from confluent_kafka import Producer
8
+ from tkati_core.clickhouse.producer import ClickhouseProducer
9
+ from tkati_core.clickhouse.settings import ClickHouseOutputSettings
10
+ from tkati_core.kafka.consumer import KafkaConsumer
11
+ from tkati_node_el.main import run_one_iteration
12
+ from tkati_node_el.settings import AppSettings
13
+
14
+
15
+ def _make_consumer(test_settings: AppSettings) -> KafkaConsumer:
16
+ return KafkaConsumer(
17
+ kafka_config={
18
+ "bootstrap.servers": test_settings.input.connection.broker,
19
+ "group.id": test_settings.input.consumer.group_id,
20
+ "auto.offset.reset": "earliest",
21
+ "enable.auto.commit": False,
22
+ },
23
+ topic_name=test_settings.input.topic.name,
24
+ input_schema=test_settings.input.topic.schema,
25
+ )
26
+
27
+
28
+ def test_node_el_valid_flow(
29
+ kafka_producer_and_topic: Producer,
30
+ ch_client: ch_driver.Client,
31
+ ch_table: str,
32
+ mock_dlq_producer: MagicMock,
33
+ test_settings: AppSettings,
34
+ ) -> None:
35
+ """Produce a valid event to Kafka, run one iteration, verify the row lands in ClickHouse."""
36
+ event = {
37
+ "uid": "abc123",
38
+ "time": int(time.time() * 1000),
39
+ "package_id": 1,
40
+ "user_hash": "uhash",
41
+ "sdk_hash": "shash",
42
+ "conn_type": "https",
43
+ "country": "US",
44
+ "local_ip": "10.0.0.1",
45
+ "frontend_ip": "1.2.3.4",
46
+ "dest_addr": "8.8.8.8",
47
+ "client_ip": "192.168.1.1",
48
+ "traffic_in": 100,
49
+ "traffic_out": 200,
50
+ }
51
+
52
+ kafka_producer_and_topic.produce(
53
+ test_settings.input.topic.name, value=orjson.dumps(event)
54
+ )
55
+ kafka_producer_and_topic.flush()
56
+
57
+ assert isinstance(test_settings.output, ClickHouseOutputSettings)
58
+
59
+ consumer = _make_consumer(test_settings)
60
+ ch_producer = ClickhouseProducer(
61
+ ch_client=ch_client,
62
+ table=ch_table,
63
+ dlq_producer=mock_dlq_producer,
64
+ )
65
+ try:
66
+ run_one_iteration(consumer, ch_producer, test_settings)
67
+ finally:
68
+ consumer.close()
69
+
70
+ result = ch_client.query(f"SELECT uid, traffic_in, traffic_out FROM {ch_table}")
71
+ assert result.result_rows == [("abc123", 100, 200)]
72
+ mock_dlq_producer.produce_arrow.assert_not_called()
73
+
74
+
75
+ def test_node_el_malformed_data(
76
+ kafka_producer_and_topic: Producer,
77
+ ch_client: ch_driver.Client,
78
+ ch_table: str,
79
+ mock_dlq_producer: MagicMock,
80
+ test_settings: AppSettings,
81
+ ) -> None:
82
+ """Produce malformed JSON to Kafka; run_one_iteration must raise with 'JSON parse error'."""
83
+ kafka_producer_and_topic.produce(
84
+ test_settings.input.topic.name, value=b"not a json object"
85
+ )
86
+ kafka_producer_and_topic.flush()
87
+
88
+ assert isinstance(test_settings.output, ClickHouseOutputSettings)
89
+
90
+ consumer = _make_consumer(test_settings)
91
+ ch_producer = ClickhouseProducer(
92
+ ch_client=ch_client,
93
+ table=ch_table,
94
+ dlq_producer=mock_dlq_producer,
95
+ )
96
+ try:
97
+ with pytest.raises(Exception, match="JSON parse error"):
98
+ run_one_iteration(consumer, ch_producer, test_settings)
99
+ finally:
100
+ consumer.close()
101
+
102
+ result = ch_client.query(f"SELECT count() FROM {ch_table}")
103
+ assert result.result_rows[0][0] == 0