tkati-node-dedup 0.4.2__tar.gz → 0.4.4__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.
Files changed (24) hide show
  1. tkati_node_dedup-0.4.4/PKG-INFO +195 -0
  2. tkati_node_dedup-0.4.4/README.md +183 -0
  3. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/pyproject.toml +2 -2
  4. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup/main.py +72 -20
  5. tkati_node_dedup-0.4.4/src/tkati_node_dedup/settings.py +89 -0
  6. tkati_node_dedup-0.4.4/src/tkati_node_dedup/store.py +411 -0
  7. tkati_node_dedup-0.4.4/src/tkati_node_dedup.egg-info/PKG-INFO +195 -0
  8. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup.egg-info/requires.txt +1 -1
  9. tkati_node_dedup-0.4.4/tests/test_store.py +293 -0
  10. tkati_node_dedup-0.4.2/PKG-INFO +0 -106
  11. tkati_node_dedup-0.4.2/README.md +0 -94
  12. tkati_node_dedup-0.4.2/src/tkati_node_dedup/settings.py +0 -23
  13. tkati_node_dedup-0.4.2/src/tkati_node_dedup/store.py +0 -224
  14. tkati_node_dedup-0.4.2/src/tkati_node_dedup.egg-info/PKG-INFO +0 -106
  15. tkati_node_dedup-0.4.2/tests/test_store.py +0 -114
  16. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/setup.cfg +0 -0
  17. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup/__init__.py +0 -0
  18. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup/__main__.py +0 -0
  19. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup/py.typed +0 -0
  20. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup.egg-info/SOURCES.txt +0 -0
  21. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup.egg-info/dependency_links.txt +0 -0
  22. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup.egg-info/entry_points.txt +0 -0
  23. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/src/tkati_node_dedup.egg-info/top_level.txt +0 -0
  24. {tkati_node_dedup-0.4.2 → tkati_node_dedup-0.4.4}/tests/test_node_dedup.py +0 -0
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: tkati-node-dedup
3
+ Version: 0.4.4
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.4
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: poll=4.43s (44%) parse=0.48s (5%) 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
+ * `poll` — fetching message batches from the broker. Mostly broker round trips,
94
+ but it also includes librdkafka handing each message to Python, which has a
95
+ floor of roughly 0.8 us/message no matter how fast the broker is
96
+ * `parse` — JSON-decoding those payloads into an Arrow table, and casting to
97
+ the internal schema
98
+ * `lookup` — encoding keys, resolving in-batch duplicates, querying the store
99
+ * `produce` — serializing and producing, including the blocking `flush`
100
+ * `write` — marking the surviving keys seen
101
+ * `commit` — the synchronous offset commit (and bucket cleanup, which is ~0
102
+ except once an hour when a bucket is destroyed)
103
+
104
+ `poll` and `parse` come from `tkati-core`'s consumer rather than from this
105
+ node, which splices them in from `CONSUMER_PHASES`. They are split apart
106
+ because their fixes are unrelated: a large `poll` points at batch sizing,
107
+ broker latency or an under-fed topic, while a large `parse` points at the JSON
108
+ decode and is what a faster wire format would address.
109
+
110
+ Percentages are of the interval, not of each other, so they **do not sum to
111
+ 100** — the remainder is time in none of the named phases.
112
+
113
+ `input-starved` counts iterations where the node drained the topic and waited
114
+ out the batch timeout. Those iterations were not CPU-bound, and because `poll`
115
+ blocks for the whole wait, a mostly-starved interval will show `poll` at close
116
+ to 100% and tells you nothing about whether the node can keep up.
117
+
118
+ `benchmarks/bench_store.py` A/B tests the store in isolation. It populates in
119
+ one process and measures in a fresh one, because a store that has just been
120
+ written has everything in its memtable and every table-level option looks
121
+ like it does nothing.
122
+
123
+ **Turning RocksDB's bloom filter on is the single largest win**, because it
124
+ was off: RocksDB has no filter policy by default, so every negative lookup —
125
+ and with rare duplicates nearly every lookup is negative — read data blocks
126
+ out of the SSTs. Enabling it cut data-block reads by ~40x.
127
+
128
+ **Caveat, and the reason the code looks the way it does:** the obvious API,
129
+ `BlockBasedOptions.set_bloom_filter()`, **does not work in rocksdict 0.3.29**
130
+ (the current release). It writes the filter into the SST files but the read
131
+ path never consults it — `rocksdb.bloom.filter.useful` stays at 0 and the
132
+ data-block read count is identical with the filter on and off. Only the
133
+ all-in-one `Options.optimize_for_point_lookup()` helper works, and calling
134
+ `set_block_based_table_factory()` after it silently undoes it. Recheck this
135
+ if rocksdict is ever upgraded.
136
+
137
+ Two changes that look obviously right for this workload measured *worse* and
138
+ are deliberately not enabled — don't "fix" them without re-running the
139
+ benchmark:
140
+
141
+ * **A larger write buffer.** 256MB measured 25% worse on writes and 20% worse
142
+ on reads than 64MB.
143
+ * **Disabling compaction.** Each bucket is deleted within the hour, so its
144
+ compaction looks like pure waste — but it bought nothing on writes (2.41 vs
145
+ 2.43 µs/key; compaction runs on background threads and never contended with
146
+ the write path) while costing 2.2x on reads as L0 files accumulated.
147
+
148
+ ## Delivery & dedup guarantees
149
+
150
+ **Delivery: at-least-once.** Offsets are committed only after (1) the
151
+ filtered batch is produced and confirmed delivered (`produce_arrow` followed
152
+ by a blocking `flush`), and (2) the surviving keys are recorded in the current
153
+ RocksDB bucket. If the process crashes between steps, the same input batch is
154
+ re-read at restart; because the keys from a completed produce are already
155
+ marked seen, re-processing that batch is a no-op (or reproduces only the
156
+ genuinely-new subset) rather than losing data.
157
+
158
+ **The dedup store is not crash-durable, by design.** With `disable_wal`
159
+ (the default) writes go to a volatile memtable, so a hard kill can lose up to
160
+ one write buffer's worth of dedup state — those keys stop being recognized as
161
+ seen, and later duplicates of them are forwarded. It can never cause an event
162
+ to be dropped, which is the tradeoff this node makes everywhere: Kafka is the
163
+ source of truth and the committed offset, not RocksDB, is the durability
164
+ boundary. A *graceful* shutdown flushes and loses nothing. Set
165
+ `dedup.rocksdb.disable_wal = false` to trade throughput for crash durability.
166
+
167
+ **On any internal dedup-store failure — a bucket won't open, a lookup errors,
168
+ a disk I/O error — the node treats the event as NOT a duplicate and forwards
169
+ it.** This node will occasionally forward a duplicate it should have caught,
170
+ but will never silently drop a real event because of dedup-store trouble.
171
+
172
+ **The window is approximate, not exact.** Because state is bucketed in
173
+ `bucket_hours` increments (default 1h) rather than a true sliding window, the
174
+ effective dedup window is between `window_hours` and
175
+ `window_hours + bucket_hours`. Stale buckets are deleted from disk
176
+ automatically once they fall outside the window — checked once per iteration,
177
+ so state never grows unbounded.
178
+
179
+ **Bucketing is by processing time**, not any timestamp field in the event
180
+ payload — an event's bucket is when this node handles it, not when it
181
+ happened upstream.
182
+
183
+ ## IMPORTANT: dedup state is local to this process
184
+
185
+ The RocksDB store lives on local disk at `store_dir` and is **not shared**
186
+ between instances. Running multiple concurrent instances of this node against
187
+ the same input topic (e.g. multiple consumers in the same consumer group, or
188
+ multiple replicas) will **not** dedup correctly across instances unless the
189
+ input is partitioned such that all events sharing a `field` value are always
190
+ routed to the *same* instance (e.g. Kafka partitioning keyed on `field`, one
191
+ node instance per partition or partition subset it exclusively owns). Running
192
+ this node with more parallelism than that will let duplicates leak through
193
+ across instance boundaries. This is a direct consequence of choosing an
194
+ embedded local-file store instead of a shared external one — evaluate this
195
+ tradeoff before scaling this node horizontally.
@@ -0,0 +1,183 @@
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: poll=4.43s (44%) parse=0.48s (5%) 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
+ * `poll` — fetching message batches from the broker. Mostly broker round trips,
82
+ but it also includes librdkafka handing each message to Python, which has a
83
+ floor of roughly 0.8 us/message no matter how fast the broker is
84
+ * `parse` — JSON-decoding those payloads into an Arrow table, and casting to
85
+ the internal schema
86
+ * `lookup` — encoding keys, resolving in-batch duplicates, querying the store
87
+ * `produce` — serializing and producing, including the blocking `flush`
88
+ * `write` — marking the surviving keys seen
89
+ * `commit` — the synchronous offset commit (and bucket cleanup, which is ~0
90
+ except once an hour when a bucket is destroyed)
91
+
92
+ `poll` and `parse` come from `tkati-core`'s consumer rather than from this
93
+ node, which splices them in from `CONSUMER_PHASES`. They are split apart
94
+ because their fixes are unrelated: a large `poll` points at batch sizing,
95
+ broker latency or an under-fed topic, while a large `parse` points at the JSON
96
+ decode and is what a faster wire format would address.
97
+
98
+ Percentages are of the interval, not of each other, so they **do not sum to
99
+ 100** — the remainder is time in none of the named phases.
100
+
101
+ `input-starved` counts iterations where the node drained the topic and waited
102
+ out the batch timeout. Those iterations were not CPU-bound, and because `poll`
103
+ blocks for the whole wait, a mostly-starved interval will show `poll` at close
104
+ to 100% and tells you nothing about whether the node can keep up.
105
+
106
+ `benchmarks/bench_store.py` A/B tests the store in isolation. It populates in
107
+ one process and measures in a fresh one, because a store that has just been
108
+ written has everything in its memtable and every table-level option looks
109
+ like it does nothing.
110
+
111
+ **Turning RocksDB's bloom filter on is the single largest win**, because it
112
+ was off: RocksDB has no filter policy by default, so every negative lookup —
113
+ and with rare duplicates nearly every lookup is negative — read data blocks
114
+ out of the SSTs. Enabling it cut data-block reads by ~40x.
115
+
116
+ **Caveat, and the reason the code looks the way it does:** the obvious API,
117
+ `BlockBasedOptions.set_bloom_filter()`, **does not work in rocksdict 0.3.29**
118
+ (the current release). It writes the filter into the SST files but the read
119
+ path never consults it — `rocksdb.bloom.filter.useful` stays at 0 and the
120
+ data-block read count is identical with the filter on and off. Only the
121
+ all-in-one `Options.optimize_for_point_lookup()` helper works, and calling
122
+ `set_block_based_table_factory()` after it silently undoes it. Recheck this
123
+ if rocksdict is ever upgraded.
124
+
125
+ Two changes that look obviously right for this workload measured *worse* and
126
+ are deliberately not enabled — don't "fix" them without re-running the
127
+ benchmark:
128
+
129
+ * **A larger write buffer.** 256MB measured 25% worse on writes and 20% worse
130
+ on reads than 64MB.
131
+ * **Disabling compaction.** Each bucket is deleted within the hour, so its
132
+ compaction looks like pure waste — but it bought nothing on writes (2.41 vs
133
+ 2.43 µs/key; compaction runs on background threads and never contended with
134
+ the write path) while costing 2.2x on reads as L0 files accumulated.
135
+
136
+ ## Delivery & dedup guarantees
137
+
138
+ **Delivery: at-least-once.** Offsets are committed only after (1) the
139
+ filtered batch is produced and confirmed delivered (`produce_arrow` followed
140
+ by a blocking `flush`), and (2) the surviving keys are recorded in the current
141
+ RocksDB bucket. If the process crashes between steps, the same input batch is
142
+ re-read at restart; because the keys from a completed produce are already
143
+ marked seen, re-processing that batch is a no-op (or reproduces only the
144
+ genuinely-new subset) rather than losing data.
145
+
146
+ **The dedup store is not crash-durable, by design.** With `disable_wal`
147
+ (the default) writes go to a volatile memtable, so a hard kill can lose up to
148
+ one write buffer's worth of dedup state — those keys stop being recognized as
149
+ seen, and later duplicates of them are forwarded. It can never cause an event
150
+ to be dropped, which is the tradeoff this node makes everywhere: Kafka is the
151
+ source of truth and the committed offset, not RocksDB, is the durability
152
+ boundary. A *graceful* shutdown flushes and loses nothing. Set
153
+ `dedup.rocksdb.disable_wal = false` to trade throughput for crash durability.
154
+
155
+ **On any internal dedup-store failure — a bucket won't open, a lookup errors,
156
+ a disk I/O error — the node treats the event as NOT a duplicate and forwards
157
+ it.** This node will occasionally forward a duplicate it should have caught,
158
+ but will never silently drop a real event because of dedup-store trouble.
159
+
160
+ **The window is approximate, not exact.** Because state is bucketed in
161
+ `bucket_hours` increments (default 1h) rather than a true sliding window, the
162
+ effective dedup window is between `window_hours` and
163
+ `window_hours + bucket_hours`. Stale buckets are deleted from disk
164
+ automatically once they fall outside the window — checked once per iteration,
165
+ so state never grows unbounded.
166
+
167
+ **Bucketing is by processing time**, not any timestamp field in the event
168
+ payload — an event's bucket is when this node handles it, not when it
169
+ happened upstream.
170
+
171
+ ## IMPORTANT: dedup state is local to this process
172
+
173
+ The RocksDB store lives on local disk at `store_dir` and is **not shared**
174
+ between instances. Running multiple concurrent instances of this node against
175
+ the same input topic (e.g. multiple consumers in the same consumer group, or
176
+ multiple replicas) will **not** dedup correctly across instances unless the
177
+ input is partitioned such that all events sharing a `field` value are always
178
+ routed to the *same* instance (e.g. Kafka partitioning keyed on `field`, one
179
+ node instance per partition or partition subset it exclusively owns). Running
180
+ this node with more parallelism than that will let duplicates leak through
181
+ across instance boundaries. This is a direct consequence of choosing an
182
+ embedded local-file store instead of a shared external one — evaluate this
183
+ tradeoff before scaling this node horizontally.
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "tkati-node-dedup"
3
- version = "0.4.2"
3
+ version = "0.4.4"
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.2",
8
+ "tkati-core==0.4.4",
9
9
  "loguru>=0.7.0",
10
10
  "pydantic-settings>=2.11.0",
11
11
  "pyarrow>=21.0.0",
@@ -1,23 +1,42 @@
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 (
4
+ CONSUMER_PHASES,
5
+ Consumer,
6
+ LoopStats,
7
+ Producer,
8
+ build_consumer,
9
+ build_producer,
10
+ )
4
11
 
5
12
  from tkati_node_dedup.settings import AppSettings
6
13
  from tkati_node_dedup.store import BucketedDedupStore
7
14
 
15
+ # Reported in this order, not sorted by duration: a stable field order is what
16
+ # makes two consecutive log lines comparable at a glance. The tail lives here
17
+ # rather than in tkati-core because these four names are this node's pipeline —
18
+ # tkati-node-el, for instance, has no lookup or write phase. The head is spliced
19
+ # in from the consumer, which owns the names it times itself against.
20
+ _PHASES = (*CONSUMER_PHASES, "lookup", "produce", "write", "commit")
21
+
22
+
23
+ def _new_stats() -> LoopStats:
24
+ return LoopStats(name="dedup", phases=_PHASES)
25
+
8
26
 
9
27
  def _dedupe_batch(
10
- batch: pa.Table, field: str, store: BucketedDedupStore
28
+ batch: pa.Table, field_name: str, store: BucketedDedupStore
11
29
  ) -> tuple[pa.Table, list[bytes]]:
12
30
  """Filter out rows whose dedup key was already seen (in-batch or in the store).
13
31
 
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.
32
+ Returns (filtered_batch, keys_to_mark_seen). Null values in `field_name`
33
+ always pass through and are never added to the store — we can't dedup on
34
+ nothing.
16
35
 
17
36
  Encoding and the store lookup are both batched (one pass over the column,
18
37
  one RocksDB round trip per open bucket) rather than done per row.
19
38
  """
20
- keys = store.encode_keys(batch.column(field))
39
+ keys = store.encode_keys(batch.column(field_name))
21
40
  keep_mask, new_keys = store.filter_duplicates(keys)
22
41
  filtered = batch.filter(keep_mask)
23
42
  return filtered, new_keys
@@ -28,55 +47,85 @@ def run_one_iteration(
28
47
  producer: Producer,
29
48
  store: BucketedDedupStore,
30
49
  settings: AppSettings,
50
+ stats: LoopStats | None = None,
31
51
  ) -> None:
52
+ stats = stats if stats is not None else _new_stats()
53
+
32
54
  # Runs first, every iteration (even if no batch arrives), and can never
33
55
  # raise. Buckets must be fresh *before* the dedupe check below runs —
34
56
  # doing this only after commit would leave a just-expired bucket open and
35
57
  # checked against for one extra iteration, and an idle node (no messages,
36
58
  # 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
-
59
+ # Timed into the "commit" bucket rather than given a phase of its own:
60
+ # it is ~0 except once an hour when a bucket is destroyed, so it shows
61
+ # up as an occasional commit spike instead of a permanent near-zero field.
62
+ with stats.phase("commit"):
63
+ try:
64
+ store.cleanup_expired()
65
+ except Exception:
66
+ logger.exception("dedup store cleanup failed; will retry next iteration")
67
+
68
+ # No phase block here: the consumer splits its own time into `poll` and
69
+ # `parse`. Wrapping it in an umbrella phase as well would double-count that
70
+ # time, and the percentages are of the interval — they are meant to fall
71
+ # short of 100%, with the shortfall being genuinely unaccounted work.
42
72
  batch = consumer.read_arrow(
43
73
  num_messages=settings.input.consumer.batch_size,
44
74
  timeout=settings.input.consumer.batch_timeout_sec,
75
+ stats=stats,
45
76
  )
77
+ stats.iterations += 1
46
78
  if batch is None:
79
+ stats.starved_iterations += 1
47
80
  return
48
81
 
49
- field = settings.dedup.field
50
- if field not in batch.column_names:
82
+ # A short batch means the node drained the topic and waited out the batch
83
+ # timeout it wasn't CPU-bound, so its timings say nothing about whether
84
+ # this node can keep up.
85
+ if len(batch) < settings.input.consumer.batch_size:
86
+ stats.starved_iterations += 1
87
+ stats.rows_in += len(batch)
88
+
89
+ field_name = settings.dedup.field
90
+ if field_name not in batch.column_names:
51
91
  logger.warning(
52
- f"Dedup field '{field}' missing from batch schema; passing batch through unfiltered"
92
+ f"Dedup field '{field_name}' missing from batch schema; "
93
+ "passing batch through unfiltered"
53
94
  )
54
95
  filtered, new_keys = batch, []
55
96
  else:
56
- filtered, new_keys = _dedupe_batch(batch, field, store)
97
+ # Includes the batch.filter() call, which is Arrow work rather than a
98
+ # store lookup — cheap enough not to be worth a sixth phase.
99
+ with stats.phase("lookup"):
100
+ filtered, new_keys = _dedupe_batch(batch, field_name, store)
57
101
 
58
102
  dropped = len(batch) - len(filtered)
103
+ stats.rows_out += len(filtered)
59
104
 
60
105
  if len(filtered) > 0:
61
- producer.produce_arrow(filtered)
106
+ with stats.phase("produce"):
107
+ producer.produce_arrow(filtered)
62
108
  # Block until actually delivered before marking anything "seen" or
63
109
  # committing. Required here even though tkati-node-el's loop skips it:
64
110
  # KafkaProducer.produce_arrow() only enqueues (non-blocking), and
65
111
  # marking a key seen before it's durably delivered would risk losing
66
112
  # the event permanently on a crash. ClickhouseProducer.flush() is a
67
113
  # no-op since its inserts are already synchronous.
68
- producer.flush()
114
+ with stats.phase("produce"):
115
+ producer.flush()
69
116
 
70
117
  # Only after a confirmed-successful produce: mark these keys seen.
71
- store.add_many(new_keys)
118
+ with stats.phase("write"):
119
+ store.add_many(new_keys)
72
120
 
73
121
  # Only after mark-seen: commit. If we crash before this line, the batch is
74
122
  # re-read at restart; those keys are already in the store, so re-processing
75
123
  # it drops what was already produced — a harmless duplicate at worst, never
76
124
  # a lost event.
77
- consumer.commit()
125
+ with stats.phase("commit"):
126
+ consumer.commit()
78
127
 
79
- logger.info(
128
+ logger.debug(
80
129
  f"Batch of {len(batch)} rows: produced {len(filtered)}, "
81
130
  f"deduped {dropped} ({len(new_keys)} newly marked seen)"
82
131
  )
@@ -97,11 +146,14 @@ def main() -> None:
97
146
  root_dir=settings.dedup.store_dir,
98
147
  window_hours=settings.dedup.window_hours,
99
148
  bucket_hours=settings.dedup.bucket_hours,
149
+ tuning=settings.dedup.rocksdb,
100
150
  )
101
151
 
152
+ stats = _new_stats()
102
153
  try:
103
154
  while True:
104
- run_one_iteration(consumer, producer, store, settings)
155
+ run_one_iteration(consumer, producer, store, settings, stats)
156
+ stats.report_if_due()
105
157
  finally:
106
158
  consumer.close()
107
159
  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