tkati-node-dedup 0.3.0__py3-none-any.whl

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.
File without changes
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,109 @@
1
+ import pyarrow as pa
2
+ from loguru import logger
3
+ from tkati_core import Consumer, Producer, build_consumer, build_producer
4
+
5
+ from tkati_node_dedup.settings import AppSettings
6
+ from tkati_node_dedup.store import BucketedDedupStore
7
+
8
+
9
+ def _dedupe_batch(
10
+ batch: pa.Table, field: str, store: BucketedDedupStore
11
+ ) -> tuple[pa.Table, list[bytes]]:
12
+ """Filter out rows whose dedup key was already seen (in-batch or in the store).
13
+
14
+ Returns (filtered_batch, keys_to_mark_seen). Null values in `field` always
15
+ pass through and are never added to the store — we can't dedup on nothing.
16
+
17
+ Encoding and the store lookup are both batched (one pass over the column,
18
+ one RocksDB round trip per open bucket) rather than done per row.
19
+ """
20
+ keys = store.encode_keys(batch.column(field))
21
+ keep_mask, new_keys = store.filter_duplicates(keys)
22
+ filtered = batch.filter(keep_mask)
23
+ return filtered, new_keys
24
+
25
+
26
+ def run_one_iteration(
27
+ consumer: Consumer,
28
+ producer: Producer,
29
+ store: BucketedDedupStore,
30
+ settings: AppSettings,
31
+ ) -> None:
32
+ # Runs first, every iteration (even if no batch arrives), and can never
33
+ # raise. Buckets must be fresh *before* the dedupe check below runs —
34
+ # doing this only after commit would leave a just-expired bucket open and
35
+ # checked against for one extra iteration, and an idle node (no messages,
36
+ # read_arrow returns None below) would never clean up at all.
37
+ try:
38
+ store.cleanup_expired()
39
+ except Exception:
40
+ logger.exception("dedup store cleanup failed; will retry next iteration")
41
+
42
+ batch = consumer.read_arrow(
43
+ num_messages=settings.input.consumer.batch_size,
44
+ timeout=settings.input.consumer.batch_timeout_sec,
45
+ )
46
+ if batch is None:
47
+ return
48
+
49
+ field = settings.dedup.field
50
+ if field not in batch.column_names:
51
+ logger.warning(
52
+ f"Dedup field '{field}' missing from batch schema; passing batch through unfiltered"
53
+ )
54
+ filtered, new_keys = batch, []
55
+ else:
56
+ filtered, new_keys = _dedupe_batch(batch, field, store)
57
+
58
+ dropped = len(batch) - len(filtered)
59
+
60
+ if len(filtered) > 0:
61
+ producer.produce_arrow(filtered)
62
+ # Block until actually delivered before marking anything "seen" or
63
+ # committing. Required here even though tkati-node-el's loop skips it:
64
+ # KafkaProducer.produce_arrow() only enqueues (non-blocking), and
65
+ # marking a key seen before it's durably delivered would risk losing
66
+ # the event permanently on a crash. ClickhouseProducer.flush() is a
67
+ # no-op since its inserts are already synchronous.
68
+ producer.flush()
69
+
70
+ # Only after a confirmed-successful produce: mark these keys seen.
71
+ store.add_many(new_keys)
72
+
73
+ # Only after mark-seen: commit. If we crash before this line, the batch is
74
+ # re-read at restart; those keys are already in the store, so re-processing
75
+ # it drops what was already produced — a harmless duplicate at worst, never
76
+ # a lost event.
77
+ consumer.commit()
78
+
79
+ logger.info(
80
+ f"Batch of {len(batch)} rows: produced {len(filtered)}, "
81
+ f"deduped {dropped} ({len(new_keys)} newly marked seen)"
82
+ )
83
+
84
+
85
+ def main() -> None:
86
+ settings = AppSettings()
87
+
88
+ consumer = build_consumer(settings.input)
89
+
90
+ dlq_producer: Producer | None = None
91
+ if settings.dlq is not None:
92
+ dlq_producer = build_producer(settings.dlq)
93
+
94
+ producer = build_producer(settings.output, dlq_producer=dlq_producer)
95
+
96
+ store = BucketedDedupStore(
97
+ root_dir=settings.dedup.store_dir,
98
+ window_hours=settings.dedup.window_hours,
99
+ bucket_hours=settings.dedup.bucket_hours,
100
+ )
101
+
102
+ try:
103
+ while True:
104
+ run_one_iteration(consumer, producer, store, settings)
105
+ finally:
106
+ consumer.close()
107
+ if dlq_producer is not None:
108
+ dlq_producer.close()
109
+ store.close()
File without changes
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel, field_validator
2
+ from tkati_core.settings import InputSettings, OutputSettings, TomlBaseSettings
3
+
4
+
5
+ class DedupSettings(BaseModel):
6
+ field: str
7
+ window_hours: int = 3
8
+ bucket_hours: int = 1
9
+ store_dir: str = "./dedup_store"
10
+
11
+ @field_validator("window_hours", "bucket_hours")
12
+ @classmethod
13
+ def _positive(cls, v: int) -> int:
14
+ if v <= 0:
15
+ raise ValueError("must be a positive number of hours")
16
+ return v
17
+
18
+
19
+ class AppSettings(TomlBaseSettings):
20
+ input: InputSettings
21
+ output: OutputSettings
22
+ dlq: OutputSettings | None = None
23
+ dedup: DedupSettings
@@ -0,0 +1,224 @@
1
+ """Bucketed, embedded RocksDB store for windowed key deduplication.
2
+
3
+ One RocksDB database directory per wall-clock-aligned hour bucket. A key is
4
+ considered "seen" if it exists in any bucket currently inside the window.
5
+ Existence-only store: values are always empty bytes, only key presence matters.
6
+
7
+ Lookups and writes are batched against RocksDB (one call per open bucket for
8
+ reads, one WriteBatch for writes) rather than one call per key — at realistic
9
+ batch sizes and millions of runs a day, per-row FFI calls into RocksDB are the
10
+ dominant cost, and rocksdict supports genuine batching for both directions.
11
+
12
+ Failure policy throughout: any per-bucket open/lookup/write failure is caught,
13
+ logged, and treated as "not seen" / skipped — never raised. This favors
14
+ forwarding a possible duplicate over silently dropping a real event.
15
+ """
16
+
17
+ import math
18
+ import shutil
19
+ import time
20
+ from collections.abc import Iterable
21
+ from pathlib import Path
22
+ from typing import cast
23
+
24
+ import pyarrow as pa
25
+ import pyarrow.compute as pc
26
+ from loguru import logger
27
+ from rocksdict import Options, Rdict, WriteBatch
28
+
29
+ _BUCKET_PREFIX = "bucket-"
30
+
31
+
32
+ def _now() -> float:
33
+ """Indirection over time.time() so tests can freeze this store's clock
34
+ without patching the global time module (which would also freeze
35
+ unrelated code, e.g. the Kafka consumer's poll-timeout bookkeeping)."""
36
+ return time.time()
37
+
38
+
39
+ def _bucket_index(ts: float, bucket_seconds: int) -> int:
40
+ return int(ts // bucket_seconds)
41
+
42
+
43
+ class BucketedDedupStore:
44
+ def __init__(self, root_dir: str, window_hours: int, bucket_hours: int = 1) -> None:
45
+ self.root = Path(root_dir)
46
+ self.root.mkdir(parents=True, exist_ok=True)
47
+ self.bucket_seconds = bucket_hours * 3600
48
+ self.num_buckets = math.ceil(window_hours / bucket_hours)
49
+ self._opts = Options(raw_mode=True)
50
+ self._dbs: dict[int, Rdict] = {}
51
+ self._recover_existing_buckets()
52
+
53
+ def _bucket_path(self, bucket: int) -> Path:
54
+ return self.root / f"{_BUCKET_PREFIX}{bucket:012d}"
55
+
56
+ def _min_live_bucket(self) -> int:
57
+ current = _bucket_index(_now(), self.bucket_seconds)
58
+ return current - self.num_buckets + 1
59
+
60
+ def _recover_existing_buckets(self) -> None:
61
+ """Reopen on-disk buckets still inside the window; destroy stale ones.
62
+
63
+ Makes dedup state survive a node restart.
64
+ """
65
+ min_live = self._min_live_bucket()
66
+ for entry in sorted(self.root.glob(f"{_BUCKET_PREFIX}*")):
67
+ if not entry.is_dir():
68
+ continue
69
+ try:
70
+ bucket = int(entry.name.removeprefix(_BUCKET_PREFIX))
71
+ except ValueError:
72
+ logger.warning(f"Ignoring unrecognized entry in dedup store dir: {entry}")
73
+ continue
74
+ if bucket < min_live:
75
+ logger.info(f"Startup: removing stale dedup bucket {bucket} ({entry})")
76
+ self._destroy_path(entry)
77
+ continue
78
+ try:
79
+ self._dbs[bucket] = Rdict(str(entry), options=self._opts)
80
+ logger.info(f"Startup: reopened dedup bucket {bucket} from {entry}")
81
+ except Exception:
82
+ logger.exception(
83
+ f"Failed to reopen dedup bucket {bucket} at {entry}; "
84
+ "starting empty for this bucket (may pass through some duplicates)"
85
+ )
86
+
87
+ def _ensure_current_open(self) -> Rdict | None:
88
+ bucket = _bucket_index(_now(), self.bucket_seconds)
89
+ if bucket in self._dbs:
90
+ return self._dbs[bucket]
91
+ try:
92
+ db = Rdict(str(self._bucket_path(bucket)), options=self._opts)
93
+ except Exception:
94
+ logger.exception(f"Failed to open current dedup bucket {bucket}")
95
+ return None
96
+ self._dbs[bucket] = db
97
+ return db
98
+
99
+ def encode_keys(self, values: pa.Array | pa.ChunkedArray) -> list[bytes | None]:
100
+ """Vectorized byte-key encoding for a column of scalar dedup-field values.
101
+
102
+ Cast to string via pyarrow compute (fast, vectorized — no per-row
103
+ Python type dispatch), then UTF-8 encode each non-null value. Nulls
104
+ stay None: they're never queried or stored, always passed through.
105
+
106
+ Float or timestamp dedup fields are discouraged: their string
107
+ representation isn't guaranteed stable across producers.
108
+ """
109
+ strings = pc.cast(values, pa.string())
110
+ return [None if s is None else s.encode("utf-8") for s in strings.to_pylist()]
111
+
112
+ def filter_duplicates(
113
+ self, keys: list[bytes | None]
114
+ ) -> tuple[pa.BooleanArray, list[bytes]]:
115
+ """
116
+ Given per-row encoded keys (None = no key, always kept), returns
117
+ (keep_mask, keys_to_mark_seen). keep_mask[i] corresponds to keys[i]
118
+ and is directly usable with pyarrow.Table.filter().
119
+
120
+ In-batch duplicates (two rows with the same key, neither yet in the
121
+ store) are resolved locally; the remaining unique candidates are
122
+ checked against the store in a single batched round trip per open
123
+ bucket, not one lookup per key.
124
+ """
125
+ keep_mask = [False] * len(keys)
126
+ seen_in_batch: set[bytes] = set()
127
+ to_check: list[bytes] = []
128
+ to_check_idx: list[int] = []
129
+
130
+ for i, key in enumerate(keys):
131
+ if key is None:
132
+ keep_mask[i] = True
133
+ continue
134
+ if key in seen_in_batch:
135
+ continue
136
+ seen_in_batch.add(key)
137
+ to_check.append(key)
138
+ to_check_idx.append(i)
139
+
140
+ already_seen = self._batch_contains(to_check)
141
+
142
+ new_keys: list[bytes] = []
143
+ for idx, key, seen in zip(to_check_idx, to_check, already_seen, strict=True):
144
+ if not seen:
145
+ keep_mask[idx] = True
146
+ new_keys.append(key)
147
+
148
+ return pa.array(keep_mask, type=pa.bool_()), new_keys
149
+
150
+ def _batch_contains(self, keys: list[bytes]) -> list[bool]:
151
+ if not keys:
152
+ return []
153
+ found = [False] * len(keys)
154
+ for bucket, db in list(self._dbs.items()):
155
+ try:
156
+ # rocksdict's stub declares `List[...]` invariantly, so
157
+ # list[bytes] isn't accepted as-is, and doesn't distinguish
158
+ # the list-in/list-out overload from the scalar one for the
159
+ # return type either — get() genuinely returns a list here
160
+ # since `keys` is a list.
161
+ keys_arg = cast("list[str | int | float | bytes]", keys)
162
+ results = cast("list[bytes | None]", db.get(keys_arg))
163
+ except Exception:
164
+ logger.exception(f"Batch lookup failed against dedup bucket {bucket}; skipping it")
165
+ continue
166
+ for i, r in enumerate(results):
167
+ if r is not None:
168
+ found[i] = True
169
+ return found
170
+
171
+ def add_many(self, keys: Iterable[bytes]) -> None:
172
+ keys = list(keys)
173
+ if not keys:
174
+ return
175
+ db = self._ensure_current_open()
176
+ if db is None:
177
+ return
178
+ try:
179
+ wb = WriteBatch(raw_mode=True)
180
+ for key in keys:
181
+ wb.put(key, b"")
182
+ db.write(wb)
183
+ except Exception:
184
+ logger.exception("Failed to batch-write keys into current dedup bucket")
185
+
186
+ def contains(self, key: bytes) -> bool:
187
+ return self._batch_contains([key])[0]
188
+
189
+ def add(self, key: bytes) -> None:
190
+ self.add_many([key])
191
+
192
+ def _destroy_path(self, path: Path) -> None:
193
+ try:
194
+ Rdict.destroy(str(path), self._opts)
195
+ except Exception:
196
+ logger.exception(f"Rdict.destroy failed for {path}; falling back to rmtree")
197
+ shutil.rmtree(path, ignore_errors=True)
198
+
199
+ def cleanup_expired(self) -> None:
200
+ """Close+delete on-disk buckets that fell out of the window.
201
+
202
+ Called at the start of every iteration (before the dedupe check runs
203
+ against possibly-stale buckets), and cheap to call every time: only
204
+ does real I/O once per hour rollover, since _min_live_bucket() only
205
+ changes then.
206
+ """
207
+ min_live = self._min_live_bucket()
208
+ for bucket in list(self._dbs.keys()):
209
+ if bucket < min_live:
210
+ db = self._dbs.pop(bucket)
211
+ try:
212
+ db.close()
213
+ except Exception:
214
+ logger.exception(f"Error closing expired dedup bucket {bucket}")
215
+ self._destroy_path(self._bucket_path(bucket))
216
+ logger.info(f"Expired dedup bucket {bucket} removed")
217
+
218
+ def close(self) -> None:
219
+ for bucket, db in self._dbs.items():
220
+ try:
221
+ db.close()
222
+ except Exception:
223
+ logger.exception(f"Error closing dedup bucket {bucket} on shutdown")
224
+ self._dbs.clear()
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: tkati-node-dedup
3
+ Version: 0.3.0
4
+ Summary: Kafka-to-Kafka streaming node that deduplicates events by a configurable field within a rolling processing-time window
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tkati-core==0.3.0
8
+ Requires-Dist: loguru>=0.7.0
9
+ Requires-Dist: pydantic-settings>=2.11.0
10
+ Requires-Dist: pyarrow>=21.0.0
11
+ Requires-Dist: rocksdict>=0.3.0
12
+
13
+ # tkati-node-dedup — streaming deduplication node
14
+
15
+ Reads batches from a Kafka input topic, drops events that are duplicates of an
16
+ event seen on the same `field` within a rolling processing-time window, and
17
+ writes the deduplicated batch to a configurable output. Duplicate state is
18
+ tracked in an embedded, on-disk RocksDB store local to this process — no
19
+ external dedup service is required.
20
+
21
+ ## Configuration
22
+
23
+ Settings are loaded from a TOML file. Set the `SETTINGS_FILE` environment
24
+ variable to point to it (defaults to `settings.toml`).
25
+
26
+ ```toml
27
+ [input]
28
+ type = "kafka"
29
+
30
+ [input.connection]
31
+ broker = "redpanda:29092"
32
+
33
+ [input.topic]
34
+ name = "raw_event"
35
+
36
+ [input.topic.schema]
37
+ uid = "string"
38
+ time = "timestamp[ms]"
39
+ # … other columns
40
+
41
+ [input.consumer]
42
+ group_id = "node-dedup-group"
43
+ batch_size = 1000
44
+ batch_timeout_sec = 10
45
+ auto_offset_reset = "latest"
46
+
47
+ [output]
48
+ type = "kafka"
49
+
50
+ [output.connection]
51
+ broker = "redpanda:29092"
52
+
53
+ [output.topic]
54
+ name = "raw_event_deduped"
55
+
56
+ [dedup]
57
+ field = "uid" # column in input.topic.schema to dedup by
58
+ window_hours = 3 # rolling dedup window
59
+ bucket_hours = 1 # on-disk bucket granularity (effective window is
60
+ # window_hours .. window_hours + bucket_hours)
61
+ store_dir = "/var/lib/tkati-node-dedup/store"
62
+ ```
63
+
64
+ Output and DLQ follow the same `OutputSettings` shape as `tkati-node-el`
65
+ (`"kafka"` or `"clickhouse"`) — see that package's README for the full
66
+ connection/table config shape.
67
+
68
+ ## Delivery & dedup guarantees
69
+
70
+ **Delivery: at-least-once.** Offsets are committed only after (1) the
71
+ filtered batch is produced and confirmed delivered (`produce_arrow` followed
72
+ by a blocking `flush`), and (2) the surviving keys are durably written to the
73
+ current RocksDB bucket. If the process crashes between steps, the same input
74
+ batch is re-read at restart; because the keys from a completed produce are
75
+ already marked seen, re-processing that batch is a no-op (or reproduces only
76
+ the genuinely-new subset) rather than losing data.
77
+
78
+ **On any internal dedup-store failure — a bucket won't open, a lookup errors,
79
+ a disk I/O error — the node treats the event as NOT a duplicate and forwards
80
+ it.** This node will occasionally forward a duplicate it should have caught,
81
+ but will never silently drop a real event because of dedup-store trouble.
82
+
83
+ **The window is approximate, not exact.** Because state is bucketed in
84
+ `bucket_hours` increments (default 1h) rather than a true sliding window, the
85
+ effective dedup window is between `window_hours` and
86
+ `window_hours + bucket_hours`. Stale buckets are deleted from disk
87
+ automatically once they fall outside the window — checked once per iteration,
88
+ so state never grows unbounded.
89
+
90
+ **Bucketing is by processing time**, not any timestamp field in the event
91
+ payload — an event's bucket is when this node handles it, not when it
92
+ happened upstream.
93
+
94
+ ## IMPORTANT: dedup state is local to this process
95
+
96
+ The RocksDB store lives on local disk at `store_dir` and is **not shared**
97
+ between instances. Running multiple concurrent instances of this node against
98
+ the same input topic (e.g. multiple consumers in the same consumer group, or
99
+ multiple replicas) will **not** dedup correctly across instances unless the
100
+ input is partitioned such that all events sharing a `field` value are always
101
+ routed to the *same* instance (e.g. Kafka partitioning keyed on `field`, one
102
+ node instance per partition or partition subset it exclusively owns). Running
103
+ this node with more parallelism than that will let duplicates leak through
104
+ across instance boundaries. This is a direct consequence of choosing an
105
+ embedded local-file store instead of a shared external one — evaluate this
106
+ tradeoff before scaling this node horizontally.
@@ -0,0 +1,11 @@
1
+ tkati_node_dedup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tkati_node_dedup/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
3
+ tkati_node_dedup/main.py,sha256=86DHLsGrhyYhWOp8CkqiCBYCqDrSFSISGh7YxgloyOQ,3914
4
+ tkati_node_dedup/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ tkati_node_dedup/settings.py,sha256=FyIUGM_pTeyAgXnOw33yhfbhbOYqQRrEfm06mi7rfqc,633
6
+ tkati_node_dedup/store.py,sha256=e5eD792Auk3hyB1KiduUcCft2uxTmOY0gyXi1JTzcK4,8904
7
+ tkati_node_dedup-0.3.0.dist-info/METADATA,sha256=S1echBtd7htveJBcrall8z5l6LpJIY5UdFpgUQwpgRQ,4167
8
+ tkati_node_dedup-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ tkati_node_dedup-0.3.0.dist-info/entry_points.txt,sha256=ivsESwayqwUo1rkYBFKUZ_FJD4lMj1-WFbd5S9ZpLto,64
10
+ tkati_node_dedup-0.3.0.dist-info/top_level.txt,sha256=dpxwzDwQ4E175J1C2ajLZZ0ya2SSPs6QT5YJNXi-b2I,17
11
+ tkati_node_dedup-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tkati-node-dedup = tkati_node_dedup.main:main
@@ -0,0 +1 @@
1
+ tkati_node_dedup