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.
Files changed (24) hide show
  1. tkati_node_dedup-0.4.3/PKG-INFO +185 -0
  2. tkati_node_dedup-0.4.3/README.md +173 -0
  3. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/pyproject.toml +2 -2
  4. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/main.py +64 -24
  5. tkati_node_dedup-0.4.3/src/tkati_node_dedup/settings.py +89 -0
  6. tkati_node_dedup-0.4.3/src/tkati_node_dedup/store.py +411 -0
  7. tkati_node_dedup-0.4.3/src/tkati_node_dedup.egg-info/PKG-INFO +185 -0
  8. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/requires.txt +1 -1
  9. tkati_node_dedup-0.4.3/tests/test_store.py +293 -0
  10. tkati_node_dedup-0.4.1/PKG-INFO +0 -106
  11. tkati_node_dedup-0.4.1/README.md +0 -94
  12. tkati_node_dedup-0.4.1/src/tkati_node_dedup/settings.py +0 -23
  13. tkati_node_dedup-0.4.1/src/tkati_node_dedup/store.py +0 -224
  14. tkati_node_dedup-0.4.1/src/tkati_node_dedup.egg-info/PKG-INFO +0 -106
  15. tkati_node_dedup-0.4.1/tests/test_store.py +0 -114
  16. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/setup.cfg +0 -0
  17. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/__init__.py +0 -0
  18. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/__main__.py +0 -0
  19. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup/py.typed +0 -0
  20. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/SOURCES.txt +0 -0
  21. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/dependency_links.txt +0 -0
  22. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/entry_points.txt +0 -0
  23. {tkati_node_dedup-0.4.1 → tkati_node_dedup-0.4.3}/src/tkati_node_dedup.egg-info/top_level.txt +0 -0
  24. {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.1"
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.1",
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, field: str, store: BucketedDedupStore
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 `field` always
15
- pass through and are never added to the store — we can't dedup on nothing.
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(field))
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
- 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
- )
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
- field = settings.dedup.field
50
- if field not in batch.column_names:
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 '{field}' missing from batch schema; passing batch through unfiltered"
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
- filtered, new_keys = _dedupe_batch(batch, field, store)
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
- producer.produce_arrow(filtered)
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
- producer.flush()
102
+ with stats.phase("produce"):
103
+ producer.flush()
69
104
 
70
105
  # Only after a confirmed-successful produce: mark these keys seen.
71
- store.add_many(new_keys)
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
- consumer.commit()
113
+ with stats.phase("commit"):
114
+ consumer.commit()
78
115
 
79
- logger.info(
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