tkati-node-dedup 0.4.1__tar.gz → 0.4.3__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.
- tkati_node_dedup-0.4.3/PKG-INFO +185 -0
- tkati_node_dedup-0.4.3/README.md +173 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/pyproject.toml +2 -2
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/main.py +64 -24
- tkati_node_dedup-0.4.3/src/tkati_node_dedup/settings.py +89 -0
- tkati_node_dedup-0.4.3/src/tkati_node_dedup/store.py +411 -0
- tkati_node_dedup-0.4.3/src/tkati_node_dedup.egg-info/PKG-INFO +185 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/requires.txt +1 -1
- tkati_node_dedup-0.4.3/tests/test_store.py +293 -0
- tkati_node_dedup-0.4.1/PKG-INFO +0 -106
- tkati_node_dedup-0.4.1/README.md +0 -94
- tkati_node_dedup-0.4.1/src/tkati_node_dedup/settings.py +0 -23
- tkati_node_dedup-0.4.1/src/tkati_node_dedup/store.py +0 -224
- tkati_node_dedup-0.4.1/src/tkati_node_dedup.egg-info/PKG-INFO +0 -106
- tkati_node_dedup-0.4.1/tests/test_store.py +0 -114
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/setup.cfg +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/__init__.py +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/__main__.py +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/py.typed +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/SOURCES.txt +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/dependency_links.txt +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/entry_points.txt +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/top_level.txt +0 -0
- {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/tests/test_node_dedup.py +0 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tkati-node-dedup
|
|
3
|
+
Version: 0.4.3
|
|
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.4.3
|
|
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
|
+
# Optional. Performance tuning for the embedded store; the defaults below are
|
|
64
|
+
# the ones shipped, and were chosen by A/B measurement rather than reasoning
|
|
65
|
+
# (see "Dedup store performance").
|
|
66
|
+
[dedup.rocksdb]
|
|
67
|
+
point_lookup_optimized = true # RocksDB bloom filter + block cache
|
|
68
|
+
memtable_bloom_ratio = 0.02 # 0 disables
|
|
69
|
+
block_cache_mb = 128
|
|
70
|
+
write_buffer_mb = 64
|
|
71
|
+
compression = "none" # "none" | "lz4" | "zstd" | "snappy"
|
|
72
|
+
disable_wal = true
|
|
73
|
+
disable_auto_compactions = false # benchmarking only; see below
|
|
74
|
+
enable_statistics = false # costs ~5-10%; diagnosis only
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Output and DLQ follow the same `OutputSettings` shape as `tkati-node-el`
|
|
78
|
+
(`"kafka"` or `"clickhouse"`) — see that package's README for the full
|
|
79
|
+
connection/table config shape.
|
|
80
|
+
|
|
81
|
+
## Dedup store performance
|
|
82
|
+
|
|
83
|
+
Every 10 seconds the node logs where its wall clock went, using
|
|
84
|
+
`LoopStats` from `tkati-core`:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
dedup perf over 10s: 157000 rows in, 153880 out (3120 dropped), 157 iterations (0 input-starved)
|
|
88
|
+
dedup perf: read=4.91s (49%) lookup=0.52s (5%) produce=3.96s (39%) write=0.21s (2%) commit=0.38s (4%)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`dropped` is the rows this node deduplicated away.
|
|
92
|
+
|
|
93
|
+
* `read` — fetching from Kafka *and* JSON-parsing into Arrow
|
|
94
|
+
* `lookup` — encoding keys, resolving in-batch duplicates, querying the store
|
|
95
|
+
* `produce` — serializing and producing, including the blocking `flush`
|
|
96
|
+
* `write` — marking the surviving keys seen
|
|
97
|
+
* `commit` — the synchronous offset commit (and bucket cleanup, which is ~0
|
|
98
|
+
except once an hour when a bucket is destroyed)
|
|
99
|
+
|
|
100
|
+
Percentages are of the interval, not of each other, so they **do not sum to
|
|
101
|
+
100** — the remainder is time in none of the named phases.
|
|
102
|
+
|
|
103
|
+
`input-starved` counts iterations where the node drained the topic and waited
|
|
104
|
+
out the batch timeout. Those iterations were not CPU-bound, and because `read`
|
|
105
|
+
blocks for the whole wait, a mostly-starved interval will show `read` at close
|
|
106
|
+
to 100% and tells you nothing about whether the node can keep up.
|
|
107
|
+
|
|
108
|
+
`benchmarks/bench_store.py` A/B tests the store in isolation. It populates in
|
|
109
|
+
one process and measures in a fresh one, because a store that has just been
|
|
110
|
+
written has everything in its memtable and every table-level option looks
|
|
111
|
+
like it does nothing.
|
|
112
|
+
|
|
113
|
+
**Turning RocksDB's bloom filter on is the single largest win**, because it
|
|
114
|
+
was off: RocksDB has no filter policy by default, so every negative lookup —
|
|
115
|
+
and with rare duplicates nearly every lookup is negative — read data blocks
|
|
116
|
+
out of the SSTs. Enabling it cut data-block reads by ~40x.
|
|
117
|
+
|
|
118
|
+
**Caveat, and the reason the code looks the way it does:** the obvious API,
|
|
119
|
+
`BlockBasedOptions.set_bloom_filter()`, **does not work in rocksdict 0.3.29**
|
|
120
|
+
(the current release). It writes the filter into the SST files but the read
|
|
121
|
+
path never consults it — `rocksdb.bloom.filter.useful` stays at 0 and the
|
|
122
|
+
data-block read count is identical with the filter on and off. Only the
|
|
123
|
+
all-in-one `Options.optimize_for_point_lookup()` helper works, and calling
|
|
124
|
+
`set_block_based_table_factory()` after it silently undoes it. Recheck this
|
|
125
|
+
if rocksdict is ever upgraded.
|
|
126
|
+
|
|
127
|
+
Two changes that look obviously right for this workload measured *worse* and
|
|
128
|
+
are deliberately not enabled — don't "fix" them without re-running the
|
|
129
|
+
benchmark:
|
|
130
|
+
|
|
131
|
+
* **A larger write buffer.** 256MB measured 25% worse on writes and 20% worse
|
|
132
|
+
on reads than 64MB.
|
|
133
|
+
* **Disabling compaction.** Each bucket is deleted within the hour, so its
|
|
134
|
+
compaction looks like pure waste — but it bought nothing on writes (2.41 vs
|
|
135
|
+
2.43 µs/key; compaction runs on background threads and never contended with
|
|
136
|
+
the write path) while costing 2.2x on reads as L0 files accumulated.
|
|
137
|
+
|
|
138
|
+
## Delivery & dedup guarantees
|
|
139
|
+
|
|
140
|
+
**Delivery: at-least-once.** Offsets are committed only after (1) the
|
|
141
|
+
filtered batch is produced and confirmed delivered (`produce_arrow` followed
|
|
142
|
+
by a blocking `flush`), and (2) the surviving keys are recorded in the current
|
|
143
|
+
RocksDB bucket. If the process crashes between steps, the same input batch is
|
|
144
|
+
re-read at restart; because the keys from a completed produce are already
|
|
145
|
+
marked seen, re-processing that batch is a no-op (or reproduces only the
|
|
146
|
+
genuinely-new subset) rather than losing data.
|
|
147
|
+
|
|
148
|
+
**The dedup store is not crash-durable, by design.** With `disable_wal`
|
|
149
|
+
(the default) writes go to a volatile memtable, so a hard kill can lose up to
|
|
150
|
+
one write buffer's worth of dedup state — those keys stop being recognized as
|
|
151
|
+
seen, and later duplicates of them are forwarded. It can never cause an event
|
|
152
|
+
to be dropped, which is the tradeoff this node makes everywhere: Kafka is the
|
|
153
|
+
source of truth and the committed offset, not RocksDB, is the durability
|
|
154
|
+
boundary. A *graceful* shutdown flushes and loses nothing. Set
|
|
155
|
+
`dedup.rocksdb.disable_wal = false` to trade throughput for crash durability.
|
|
156
|
+
|
|
157
|
+
**On any internal dedup-store failure — a bucket won't open, a lookup errors,
|
|
158
|
+
a disk I/O error — the node treats the event as NOT a duplicate and forwards
|
|
159
|
+
it.** This node will occasionally forward a duplicate it should have caught,
|
|
160
|
+
but will never silently drop a real event because of dedup-store trouble.
|
|
161
|
+
|
|
162
|
+
**The window is approximate, not exact.** Because state is bucketed in
|
|
163
|
+
`bucket_hours` increments (default 1h) rather than a true sliding window, the
|
|
164
|
+
effective dedup window is between `window_hours` and
|
|
165
|
+
`window_hours + bucket_hours`. Stale buckets are deleted from disk
|
|
166
|
+
automatically once they fall outside the window — checked once per iteration,
|
|
167
|
+
so state never grows unbounded.
|
|
168
|
+
|
|
169
|
+
**Bucketing is by processing time**, not any timestamp field in the event
|
|
170
|
+
payload — an event's bucket is when this node handles it, not when it
|
|
171
|
+
happened upstream.
|
|
172
|
+
|
|
173
|
+
## IMPORTANT: dedup state is local to this process
|
|
174
|
+
|
|
175
|
+
The RocksDB store lives on local disk at `store_dir` and is **not shared**
|
|
176
|
+
between instances. Running multiple concurrent instances of this node against
|
|
177
|
+
the same input topic (e.g. multiple consumers in the same consumer group, or
|
|
178
|
+
multiple replicas) will **not** dedup correctly across instances unless the
|
|
179
|
+
input is partitioned such that all events sharing a `field` value are always
|
|
180
|
+
routed to the *same* instance (e.g. Kafka partitioning keyed on `field`, one
|
|
181
|
+
node instance per partition or partition subset it exclusively owns). Running
|
|
182
|
+
this node with more parallelism than that will let duplicates leak through
|
|
183
|
+
across instance boundaries. This is a direct consequence of choosing an
|
|
184
|
+
embedded local-file store instead of a shared external one — evaluate this
|
|
185
|
+
tradeoff before scaling this node horizontally.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# tkati-node-dedup — streaming deduplication node
|
|
2
|
+
|
|
3
|
+
Reads batches from a Kafka input topic, drops events that are duplicates of an
|
|
4
|
+
event seen on the same `field` within a rolling processing-time window, and
|
|
5
|
+
writes the deduplicated batch to a configurable output. Duplicate state is
|
|
6
|
+
tracked in an embedded, on-disk RocksDB store local to this process — no
|
|
7
|
+
external dedup service is required.
|
|
8
|
+
|
|
9
|
+
## Configuration
|
|
10
|
+
|
|
11
|
+
Settings are loaded from a TOML file. Set the `SETTINGS_FILE` environment
|
|
12
|
+
variable to point to it (defaults to `settings.toml`).
|
|
13
|
+
|
|
14
|
+
```toml
|
|
15
|
+
[input]
|
|
16
|
+
type = "kafka"
|
|
17
|
+
|
|
18
|
+
[input.connection]
|
|
19
|
+
broker = "redpanda:29092"
|
|
20
|
+
|
|
21
|
+
[input.topic]
|
|
22
|
+
name = "raw_event"
|
|
23
|
+
|
|
24
|
+
[input.topic.schema]
|
|
25
|
+
uid = "string"
|
|
26
|
+
time = "timestamp[ms]"
|
|
27
|
+
# … other columns
|
|
28
|
+
|
|
29
|
+
[input.consumer]
|
|
30
|
+
group_id = "node-dedup-group"
|
|
31
|
+
batch_size = 1000
|
|
32
|
+
batch_timeout_sec = 10
|
|
33
|
+
auto_offset_reset = "latest"
|
|
34
|
+
|
|
35
|
+
[output]
|
|
36
|
+
type = "kafka"
|
|
37
|
+
|
|
38
|
+
[output.connection]
|
|
39
|
+
broker = "redpanda:29092"
|
|
40
|
+
|
|
41
|
+
[output.topic]
|
|
42
|
+
name = "raw_event_deduped"
|
|
43
|
+
|
|
44
|
+
[dedup]
|
|
45
|
+
field = "uid" # column in input.topic.schema to dedup by
|
|
46
|
+
window_hours = 3 # rolling dedup window
|
|
47
|
+
bucket_hours = 1 # on-disk bucket granularity (effective window is
|
|
48
|
+
# window_hours .. window_hours + bucket_hours)
|
|
49
|
+
store_dir = "/var/lib/tkati-node-dedup/store"
|
|
50
|
+
|
|
51
|
+
# Optional. Performance tuning for the embedded store; the defaults below are
|
|
52
|
+
# the ones shipped, and were chosen by A/B measurement rather than reasoning
|
|
53
|
+
# (see "Dedup store performance").
|
|
54
|
+
[dedup.rocksdb]
|
|
55
|
+
point_lookup_optimized = true # RocksDB bloom filter + block cache
|
|
56
|
+
memtable_bloom_ratio = 0.02 # 0 disables
|
|
57
|
+
block_cache_mb = 128
|
|
58
|
+
write_buffer_mb = 64
|
|
59
|
+
compression = "none" # "none" | "lz4" | "zstd" | "snappy"
|
|
60
|
+
disable_wal = true
|
|
61
|
+
disable_auto_compactions = false # benchmarking only; see below
|
|
62
|
+
enable_statistics = false # costs ~5-10%; diagnosis only
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Output and DLQ follow the same `OutputSettings` shape as `tkati-node-el`
|
|
66
|
+
(`"kafka"` or `"clickhouse"`) — see that package's README for the full
|
|
67
|
+
connection/table config shape.
|
|
68
|
+
|
|
69
|
+
## Dedup store performance
|
|
70
|
+
|
|
71
|
+
Every 10 seconds the node logs where its wall clock went, using
|
|
72
|
+
`LoopStats` from `tkati-core`:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
dedup perf over 10s: 157000 rows in, 153880 out (3120 dropped), 157 iterations (0 input-starved)
|
|
76
|
+
dedup perf: read=4.91s (49%) lookup=0.52s (5%) produce=3.96s (39%) write=0.21s (2%) commit=0.38s (4%)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`dropped` is the rows this node deduplicated away.
|
|
80
|
+
|
|
81
|
+
* `read` — fetching from Kafka *and* JSON-parsing into Arrow
|
|
82
|
+
* `lookup` — encoding keys, resolving in-batch duplicates, querying the store
|
|
83
|
+
* `produce` — serializing and producing, including the blocking `flush`
|
|
84
|
+
* `write` — marking the surviving keys seen
|
|
85
|
+
* `commit` — the synchronous offset commit (and bucket cleanup, which is ~0
|
|
86
|
+
except once an hour when a bucket is destroyed)
|
|
87
|
+
|
|
88
|
+
Percentages are of the interval, not of each other, so they **do not sum to
|
|
89
|
+
100** — the remainder is time in none of the named phases.
|
|
90
|
+
|
|
91
|
+
`input-starved` counts iterations where the node drained the topic and waited
|
|
92
|
+
out the batch timeout. Those iterations were not CPU-bound, and because `read`
|
|
93
|
+
blocks for the whole wait, a mostly-starved interval will show `read` at close
|
|
94
|
+
to 100% and tells you nothing about whether the node can keep up.
|
|
95
|
+
|
|
96
|
+
`benchmarks/bench_store.py` A/B tests the store in isolation. It populates in
|
|
97
|
+
one process and measures in a fresh one, because a store that has just been
|
|
98
|
+
written has everything in its memtable and every table-level option looks
|
|
99
|
+
like it does nothing.
|
|
100
|
+
|
|
101
|
+
**Turning RocksDB's bloom filter on is the single largest win**, because it
|
|
102
|
+
was off: RocksDB has no filter policy by default, so every negative lookup —
|
|
103
|
+
and with rare duplicates nearly every lookup is negative — read data blocks
|
|
104
|
+
out of the SSTs. Enabling it cut data-block reads by ~40x.
|
|
105
|
+
|
|
106
|
+
**Caveat, and the reason the code looks the way it does:** the obvious API,
|
|
107
|
+
`BlockBasedOptions.set_bloom_filter()`, **does not work in rocksdict 0.3.29**
|
|
108
|
+
(the current release). It writes the filter into the SST files but the read
|
|
109
|
+
path never consults it — `rocksdb.bloom.filter.useful` stays at 0 and the
|
|
110
|
+
data-block read count is identical with the filter on and off. Only the
|
|
111
|
+
all-in-one `Options.optimize_for_point_lookup()` helper works, and calling
|
|
112
|
+
`set_block_based_table_factory()` after it silently undoes it. Recheck this
|
|
113
|
+
if rocksdict is ever upgraded.
|
|
114
|
+
|
|
115
|
+
Two changes that look obviously right for this workload measured *worse* and
|
|
116
|
+
are deliberately not enabled — don't "fix" them without re-running the
|
|
117
|
+
benchmark:
|
|
118
|
+
|
|
119
|
+
* **A larger write buffer.** 256MB measured 25% worse on writes and 20% worse
|
|
120
|
+
on reads than 64MB.
|
|
121
|
+
* **Disabling compaction.** Each bucket is deleted within the hour, so its
|
|
122
|
+
compaction looks like pure waste — but it bought nothing on writes (2.41 vs
|
|
123
|
+
2.43 µs/key; compaction runs on background threads and never contended with
|
|
124
|
+
the write path) while costing 2.2x on reads as L0 files accumulated.
|
|
125
|
+
|
|
126
|
+
## Delivery & dedup guarantees
|
|
127
|
+
|
|
128
|
+
**Delivery: at-least-once.** Offsets are committed only after (1) the
|
|
129
|
+
filtered batch is produced and confirmed delivered (`produce_arrow` followed
|
|
130
|
+
by a blocking `flush`), and (2) the surviving keys are recorded in the current
|
|
131
|
+
RocksDB bucket. If the process crashes between steps, the same input batch is
|
|
132
|
+
re-read at restart; because the keys from a completed produce are already
|
|
133
|
+
marked seen, re-processing that batch is a no-op (or reproduces only the
|
|
134
|
+
genuinely-new subset) rather than losing data.
|
|
135
|
+
|
|
136
|
+
**The dedup store is not crash-durable, by design.** With `disable_wal`
|
|
137
|
+
(the default) writes go to a volatile memtable, so a hard kill can lose up to
|
|
138
|
+
one write buffer's worth of dedup state — those keys stop being recognized as
|
|
139
|
+
seen, and later duplicates of them are forwarded. It can never cause an event
|
|
140
|
+
to be dropped, which is the tradeoff this node makes everywhere: Kafka is the
|
|
141
|
+
source of truth and the committed offset, not RocksDB, is the durability
|
|
142
|
+
boundary. A *graceful* shutdown flushes and loses nothing. Set
|
|
143
|
+
`dedup.rocksdb.disable_wal = false` to trade throughput for crash durability.
|
|
144
|
+
|
|
145
|
+
**On any internal dedup-store failure — a bucket won't open, a lookup errors,
|
|
146
|
+
a disk I/O error — the node treats the event as NOT a duplicate and forwards
|
|
147
|
+
it.** This node will occasionally forward a duplicate it should have caught,
|
|
148
|
+
but will never silently drop a real event because of dedup-store trouble.
|
|
149
|
+
|
|
150
|
+
**The window is approximate, not exact.** Because state is bucketed in
|
|
151
|
+
`bucket_hours` increments (default 1h) rather than a true sliding window, the
|
|
152
|
+
effective dedup window is between `window_hours` and
|
|
153
|
+
`window_hours + bucket_hours`. Stale buckets are deleted from disk
|
|
154
|
+
automatically once they fall outside the window — checked once per iteration,
|
|
155
|
+
so state never grows unbounded.
|
|
156
|
+
|
|
157
|
+
**Bucketing is by processing time**, not any timestamp field in the event
|
|
158
|
+
payload — an event's bucket is when this node handles it, not when it
|
|
159
|
+
happened upstream.
|
|
160
|
+
|
|
161
|
+
## IMPORTANT: dedup state is local to this process
|
|
162
|
+
|
|
163
|
+
The RocksDB store lives on local disk at `store_dir` and is **not shared**
|
|
164
|
+
between instances. Running multiple concurrent instances of this node against
|
|
165
|
+
the same input topic (e.g. multiple consumers in the same consumer group, or
|
|
166
|
+
multiple replicas) will **not** dedup correctly across instances unless the
|
|
167
|
+
input is partitioned such that all events sharing a `field` value are always
|
|
168
|
+
routed to the *same* instance (e.g. Kafka partitioning keyed on `field`, one
|
|
169
|
+
node instance per partition or partition subset it exclusively owns). Running
|
|
170
|
+
this node with more parallelism than that will let duplicates leak through
|
|
171
|
+
across instance boundaries. This is a direct consequence of choosing an
|
|
172
|
+
embedded local-file store instead of a shared external one — evaluate this
|
|
173
|
+
tradeoff before scaling this node horizontally.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "tkati-node-dedup"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.3"
|
|
4
4
|
description = "Kafka-to-Kafka streaming node that deduplicates events by a configurable field within a rolling processing-time window"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"tkati-core==0.4.
|
|
8
|
+
"tkati-core==0.4.3",
|
|
9
9
|
"loguru>=0.7.0",
|
|
10
10
|
"pydantic-settings>=2.11.0",
|
|
11
11
|
"pyarrow>=21.0.0",
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
import pyarrow as pa
|
|
2
2
|
from loguru import logger
|
|
3
|
-
from tkati_core import Consumer, Producer, build_consumer, build_producer
|
|
3
|
+
from tkati_core import Consumer, LoopStats, Producer, build_consumer, build_producer
|
|
4
4
|
|
|
5
5
|
from tkati_node_dedup.settings import AppSettings
|
|
6
6
|
from tkati_node_dedup.store import BucketedDedupStore
|
|
7
7
|
|
|
8
|
+
# Reported in this order, not sorted by duration: a stable field order is what
|
|
9
|
+
# makes two consecutive log lines comparable at a glance. Lives here rather
|
|
10
|
+
# than in tkati-core because these five names are this node's pipeline —
|
|
11
|
+
# tkati-node-el, for instance, has no lookup or write phase.
|
|
12
|
+
_PHASES = ("read", "lookup", "produce", "write", "commit")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _new_stats() -> LoopStats:
|
|
16
|
+
return LoopStats(name="dedup", phases=_PHASES)
|
|
17
|
+
|
|
8
18
|
|
|
9
19
|
def _dedupe_batch(
|
|
10
|
-
batch: pa.Table,
|
|
20
|
+
batch: pa.Table, field_name: str, store: BucketedDedupStore
|
|
11
21
|
) -> tuple[pa.Table, list[bytes]]:
|
|
12
22
|
"""Filter out rows whose dedup key was already seen (in-batch or in the store).
|
|
13
23
|
|
|
14
|
-
Returns (filtered_batch, keys_to_mark_seen). Null values in `
|
|
15
|
-
pass through and are never added to the store — we can't dedup on
|
|
24
|
+
Returns (filtered_batch, keys_to_mark_seen). Null values in `field_name`
|
|
25
|
+
always pass through and are never added to the store — we can't dedup on
|
|
26
|
+
nothing.
|
|
16
27
|
|
|
17
28
|
Encoding and the store lookup are both batched (one pass over the column,
|
|
18
29
|
one RocksDB round trip per open bucket) rather than done per row.
|
|
19
30
|
"""
|
|
20
|
-
keys = store.encode_keys(batch.column(
|
|
31
|
+
keys = store.encode_keys(batch.column(field_name))
|
|
21
32
|
keep_mask, new_keys = store.filter_duplicates(keys)
|
|
22
33
|
filtered = batch.filter(keep_mask)
|
|
23
34
|
return filtered, new_keys
|
|
@@ -28,55 +39,81 @@ def run_one_iteration(
|
|
|
28
39
|
producer: Producer,
|
|
29
40
|
store: BucketedDedupStore,
|
|
30
41
|
settings: AppSettings,
|
|
42
|
+
stats: LoopStats | None = None,
|
|
31
43
|
) -> None:
|
|
44
|
+
stats = stats if stats is not None else _new_stats()
|
|
45
|
+
|
|
32
46
|
# Runs first, every iteration (even if no batch arrives), and can never
|
|
33
47
|
# raise. Buckets must be fresh *before* the dedupe check below runs —
|
|
34
48
|
# doing this only after commit would leave a just-expired bucket open and
|
|
35
49
|
# checked against for one extra iteration, and an idle node (no messages,
|
|
36
50
|
# read_arrow returns None below) would never clean up at all.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
# Timed into the "commit" bucket rather than given a phase of its own:
|
|
52
|
+
# it is ~0 except once an hour when a bucket is destroyed, so it shows
|
|
53
|
+
# up as an occasional commit spike instead of a permanent near-zero field.
|
|
54
|
+
with stats.phase("commit"):
|
|
55
|
+
try:
|
|
56
|
+
store.cleanup_expired()
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.exception("dedup store cleanup failed; will retry next iteration")
|
|
59
|
+
|
|
60
|
+
with stats.phase("read"):
|
|
61
|
+
batch = consumer.read_arrow(
|
|
62
|
+
num_messages=settings.input.consumer.batch_size,
|
|
63
|
+
timeout=settings.input.consumer.batch_timeout_sec,
|
|
64
|
+
)
|
|
65
|
+
stats.iterations += 1
|
|
46
66
|
if batch is None:
|
|
67
|
+
stats.starved_iterations += 1
|
|
47
68
|
return
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
# A short batch means the node drained the topic and waited out the batch
|
|
71
|
+
# timeout — it wasn't CPU-bound, so its timings say nothing about whether
|
|
72
|
+
# this node can keep up.
|
|
73
|
+
if len(batch) < settings.input.consumer.batch_size:
|
|
74
|
+
stats.starved_iterations += 1
|
|
75
|
+
stats.rows_in += len(batch)
|
|
76
|
+
|
|
77
|
+
field_name = settings.dedup.field
|
|
78
|
+
if field_name not in batch.column_names:
|
|
51
79
|
logger.warning(
|
|
52
|
-
f"Dedup field '{
|
|
80
|
+
f"Dedup field '{field_name}' missing from batch schema; "
|
|
81
|
+
"passing batch through unfiltered"
|
|
53
82
|
)
|
|
54
83
|
filtered, new_keys = batch, []
|
|
55
84
|
else:
|
|
56
|
-
|
|
85
|
+
# Includes the batch.filter() call, which is Arrow work rather than a
|
|
86
|
+
# store lookup — cheap enough not to be worth a sixth phase.
|
|
87
|
+
with stats.phase("lookup"):
|
|
88
|
+
filtered, new_keys = _dedupe_batch(batch, field_name, store)
|
|
57
89
|
|
|
58
90
|
dropped = len(batch) - len(filtered)
|
|
91
|
+
stats.rows_out += len(filtered)
|
|
59
92
|
|
|
60
93
|
if len(filtered) > 0:
|
|
61
|
-
|
|
94
|
+
with stats.phase("produce"):
|
|
95
|
+
producer.produce_arrow(filtered)
|
|
62
96
|
# Block until actually delivered before marking anything "seen" or
|
|
63
97
|
# committing. Required here even though tkati-node-el's loop skips it:
|
|
64
98
|
# KafkaProducer.produce_arrow() only enqueues (non-blocking), and
|
|
65
99
|
# marking a key seen before it's durably delivered would risk losing
|
|
66
100
|
# the event permanently on a crash. ClickhouseProducer.flush() is a
|
|
67
101
|
# no-op since its inserts are already synchronous.
|
|
68
|
-
|
|
102
|
+
with stats.phase("produce"):
|
|
103
|
+
producer.flush()
|
|
69
104
|
|
|
70
105
|
# Only after a confirmed-successful produce: mark these keys seen.
|
|
71
|
-
|
|
106
|
+
with stats.phase("write"):
|
|
107
|
+
store.add_many(new_keys)
|
|
72
108
|
|
|
73
109
|
# Only after mark-seen: commit. If we crash before this line, the batch is
|
|
74
110
|
# re-read at restart; those keys are already in the store, so re-processing
|
|
75
111
|
# it drops what was already produced — a harmless duplicate at worst, never
|
|
76
112
|
# a lost event.
|
|
77
|
-
|
|
113
|
+
with stats.phase("commit"):
|
|
114
|
+
consumer.commit()
|
|
78
115
|
|
|
79
|
-
logger.
|
|
116
|
+
logger.debug(
|
|
80
117
|
f"Batch of {len(batch)} rows: produced {len(filtered)}, "
|
|
81
118
|
f"deduped {dropped} ({len(new_keys)} newly marked seen)"
|
|
82
119
|
)
|
|
@@ -97,11 +134,14 @@ def main() -> None:
|
|
|
97
134
|
root_dir=settings.dedup.store_dir,
|
|
98
135
|
window_hours=settings.dedup.window_hours,
|
|
99
136
|
bucket_hours=settings.dedup.bucket_hours,
|
|
137
|
+
tuning=settings.dedup.rocksdb,
|
|
100
138
|
)
|
|
101
139
|
|
|
140
|
+
stats = _new_stats()
|
|
102
141
|
try:
|
|
103
142
|
while True:
|
|
104
|
-
run_one_iteration(consumer, producer, store, settings)
|
|
143
|
+
run_one_iteration(consumer, producer, store, settings, stats)
|
|
144
|
+
stats.report_if_due()
|
|
105
145
|
finally:
|
|
106
146
|
consumer.close()
|
|
107
147
|
if dlq_producer is not None:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, field_validator
|
|
4
|
+
from tkati_core.settings import InputSettings, OutputSettings, TomlBaseSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RocksDBSettings(BaseModel):
|
|
8
|
+
"""Tuning knobs for the embedded dedup store.
|
|
9
|
+
|
|
10
|
+
The defaults here are chosen for this node's actual workload — a store
|
|
11
|
+
whose lookups almost always miss (duplicates are rare) and whose buckets
|
|
12
|
+
are written for one hour and then deleted — and they differ substantially
|
|
13
|
+
from RocksDB's own defaults. See `store.py` for the reasoning behind each.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Enables RocksDB's bloom filter (10 bits/key) plus a block cache of
|
|
17
|
+
# `block_cache_mb` and an in-data-block hash index. There is no
|
|
18
|
+
# bits-per-key knob: the only bloom-filter API that actually works in
|
|
19
|
+
# rocksdict is the all-in-one `optimize_for_point_lookup` helper, which
|
|
20
|
+
# hardcodes 10. See BucketedDedupStore._build_options for the evidence.
|
|
21
|
+
# Off is the pre-tuning behavior, kept expressible for A/B benchmarking.
|
|
22
|
+
point_lookup_optimized: bool = True
|
|
23
|
+
|
|
24
|
+
# Bloom filter for the memtable, which on the current bucket holds this
|
|
25
|
+
# hour's keys. Measured against a warm memtable, 0 -> 0.02 takes an
|
|
26
|
+
# all-miss lookup from 3.23 to 0.85 µs/key at no write cost. Higher
|
|
27
|
+
# ratios are worse, not better (0.05 -> 1.22, 0.10 -> 1.51): a larger
|
|
28
|
+
# bloom probes with worse cache locality. 0.02 is also what
|
|
29
|
+
# optimize_for_point_lookup picks. 0 disables.
|
|
30
|
+
memtable_bloom_ratio: float = 0.02
|
|
31
|
+
|
|
32
|
+
block_cache_mb: int = 128
|
|
33
|
+
|
|
34
|
+
# 64MB, not larger. A 256MB buffer measured 25% worse on writes (deeper
|
|
35
|
+
# skiplist, larger memtable bloom to populate) and 20% worse on reads.
|
|
36
|
+
write_buffer_mb: int = 64
|
|
37
|
+
|
|
38
|
+
# Leave compaction ON. Each bucket is deleted within the hour, so skipping
|
|
39
|
+
# compaction looks free — but measured, it bought nothing on writes (2.41
|
|
40
|
+
# vs 2.43 µs/key; compaction runs on background threads and never
|
|
41
|
+
# contended with the write path) while costing 2.2x on reads (1.37 -> 2.96
|
|
42
|
+
# µs/key) as L0 files accumulated. Kept as a knob only for benchmarking.
|
|
43
|
+
disable_auto_compactions: bool = False
|
|
44
|
+
|
|
45
|
+
# Snappy is RocksDB's default and costs 2x on reads here (2.84 vs 1.37
|
|
46
|
+
# µs/key). Dedup keys are high-entropy and barely compress, and the store
|
|
47
|
+
# is ephemeral, so paying for compression buys little disk and costs real
|
|
48
|
+
# CPU.
|
|
49
|
+
compression: Literal["none", "lz4", "zstd", "snappy"] = "none"
|
|
50
|
+
|
|
51
|
+
disable_wal: bool = True
|
|
52
|
+
enable_statistics: bool = False
|
|
53
|
+
|
|
54
|
+
@field_validator("block_cache_mb", "write_buffer_mb")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _positive_mb(cls, v: int) -> int:
|
|
57
|
+
if v <= 0:
|
|
58
|
+
raise ValueError("must be a positive number of megabytes")
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
@field_validator("memtable_bloom_ratio")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _ratio(cls, v: float) -> float:
|
|
64
|
+
# RocksDB caps this at 0.25 internally; 0 disables the memtable bloom.
|
|
65
|
+
if not 0.0 <= v < 1.0:
|
|
66
|
+
raise ValueError("must be in [0.0, 1.0)")
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DedupSettings(BaseModel):
|
|
71
|
+
field: str
|
|
72
|
+
window_hours: int = 3
|
|
73
|
+
bucket_hours: int = 1
|
|
74
|
+
store_dir: str = "./dedup_store"
|
|
75
|
+
rocksdb: RocksDBSettings = RocksDBSettings()
|
|
76
|
+
|
|
77
|
+
@field_validator("window_hours", "bucket_hours")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _positive(cls, v: int) -> int:
|
|
80
|
+
if v <= 0:
|
|
81
|
+
raise ValueError("must be a positive number of hours")
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AppSettings(TomlBaseSettings):
|
|
86
|
+
input: InputSettings
|
|
87
|
+
output: OutputSettings
|
|
88
|
+
dlq: OutputSettings | None = None
|
|
89
|
+
dedup: DedupSettings
|