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.
- tkati_node_dedup/__init__.py +0 -0
- tkati_node_dedup/__main__.py +4 -0
- tkati_node_dedup/main.py +109 -0
- tkati_node_dedup/py.typed +0 -0
- tkati_node_dedup/settings.py +23 -0
- tkati_node_dedup/store.py +224 -0
- tkati_node_dedup-0.3.0.dist-info/METADATA +106 -0
- tkati_node_dedup-0.3.0.dist-info/RECORD +11 -0
- tkati_node_dedup-0.3.0.dist-info/WHEEL +5 -0
- tkati_node_dedup-0.3.0.dist-info/entry_points.txt +2 -0
- tkati_node_dedup-0.3.0.dist-info/top_level.txt +1 -0
|
File without changes
|
tkati_node_dedup/main.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
tkati_node_dedup
|