dionysus-rb 0.5.0 → 1.1.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64406a4ca852cfc6d663586730005d9c562121571cb0b876de7584eb63b2c49f
4
- data.tar.gz: c30a51ca9f590795e55021209f274d67a18aa197bd2ebb7be3ed471940ee7f6a
3
+ metadata.gz: d190bbc3fd4b2ca0aa7c7d1be7c0ddaa6a3b6bdcbd6556b90e4752c2e875bfc6
4
+ data.tar.gz: 33379f5ccc97bceee7caad1d9b3383e84c3073e48b15bda1d0f592e765c39352
5
5
  SHA512:
6
- metadata.gz: bd48b03ee34b15dd0481733a9bdd726ef23c871d2a6a19249501ffa5ebb200559a2c301785a0e9449e79059eb98f3166da2c70049d087003dc2684c89ab0a013
7
- data.tar.gz: 13f6d2e1068ea38a8c8954e594fe36382d8d6a061228c5f1f9c5de722651c495363be8569874757ce8f1fe41232495597676532d7e56da1a5a3fe25a4c0a32f1
6
+ metadata.gz: cafb4cb76ad00f5dbbc45f9c3a578284ef554aaf680310286961d1f8251620bc67c10e190f301ac06fca1c8e55be9fab2e75977cc85ecf80f21503504558da20
7
+ data.tar.gz: 78e8c07d652f3dcc46cb3a527834cb72b2290b119266e4362cde3ddbf1d5679c147cb03cce34acb6682451c6453fb58532faab8a7bf5be43a6db6be791e7e1e2
data/.circleci/config.yml CHANGED
@@ -4,7 +4,7 @@ jobs:
4
4
  working_directory: ~/BookingSync/dionysus-rb
5
5
  parallelism: 1
6
6
  docker:
7
- - image: cimg/ruby:2.7.6
7
+ - image: cimg/ruby:3.3.11
8
8
  environment:
9
9
  POSTGRES_URL: "postgres://docker:docker@127.0.0.1:5432"
10
10
  DATABASE_URL: "postgres://docker:docker@127.0.0.1:5432/circle_test"
@@ -6,10 +6,10 @@ jobs:
6
6
  fail-fast: true
7
7
  runs-on: ubuntu-latest
8
8
  steps:
9
- - uses: actions/checkout@v2
9
+ - uses: actions/checkout@v4
10
10
  - uses: ruby/setup-ruby@v1
11
11
  with:
12
- ruby-version: 2.7
12
+ ruby-version: '3.3'
13
13
  bundler-cache: true
14
14
  - run: bundle exec rubocop
15
15
  rspec:
@@ -17,7 +17,7 @@ jobs:
17
17
  strategy:
18
18
  fail-fast: false
19
19
  matrix:
20
- ruby: ['2.7', '3.0.6', '3.1', '3.2']
20
+ ruby: ['3.3', '3.4']
21
21
  runs-on: ubuntu-latest
22
22
  env:
23
23
  DD_PROFILING_NO_EXTENSION: true
@@ -27,23 +27,6 @@ jobs:
27
27
  POSTGRES_USER: postgres
28
28
  POSTGRES_PASSWORD: postgres
29
29
  POSTGRES_DB: dionysus-rb-test
30
- CLUSTER_ID: kafka-docker-cluster-1
31
- KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
32
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
33
- KAFKA_PROCESS_ROLES: broker,controller
34
- KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
35
- KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
36
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
37
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092
38
- KAFKA_BROKER_ID: 1
39
- KAFKA_CONTROLLER_QUORUM_VOTERS: 1@127.0.0.1:9093
40
- ALLOW_PLAINTEXT_LISTENER: 'yes'
41
- KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
42
- KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
43
- KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
44
- KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
45
- KAFKA_AUTHORIZER_CLASS_NAME: org.apache.kafka.metadata.authorizer.StandardAuthorizer
46
- KAFKA_CREATE_TOPICS: "v102_rentals:1:1"
47
30
  services:
48
31
  postgres:
49
32
  image: postgres:14
@@ -63,14 +46,34 @@ jobs:
63
46
  --health-interval 10s
64
47
  --health-timeout 5s
65
48
  --health-retries 5
49
+ kafka:
50
+ image: confluentinc/cp-kafka:7.5.2
51
+ env:
52
+ CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
53
+ KAFKA_NODE_ID: 1
54
+ KAFKA_PROCESS_ROLES: broker,controller
55
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
56
+ KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
57
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
58
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
59
+ KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
60
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
61
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
62
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
63
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
64
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
65
+ KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
66
+ ports:
67
+ - 9092:9092
68
+ options: >-
69
+ --health-cmd "kafka-topics --bootstrap-server localhost:9092 --list"
70
+ --health-interval 10s
71
+ --health-timeout 10s
72
+ --health-retries 12
73
+ --health-start-period 20s
66
74
  steps:
67
- - uses: actions/checkout@v2
68
- - name: Start Kafka with docker-compose
69
- run: |
70
- docker-compose up -d || (sleep 5 && docker-compose up -d)
71
-
75
+ - uses: actions/checkout@v4
72
76
  - uses: ruby/setup-ruby@v1
73
-
74
77
  with:
75
78
  ruby-version: ${{ matrix.ruby }}
76
79
  bundler-cache: true
data/.rubocop.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
- require:
3
+ plugins:
4
4
  - rubocop-performance
5
5
  - rubocop-rspec
6
6
  - rubocop-rake
@@ -11,7 +11,7 @@ inherit_mode:
11
11
 
12
12
  AllCops:
13
13
  NewCops: enable
14
- TargetRubyVersion: 2.7
14
+ TargetRubyVersion: 3.3
15
15
  Exclude:
16
16
  - "db/**/*"
17
17
  - "bin/**/*"
@@ -129,6 +129,21 @@ RSpec/ExampleLength:
129
129
  Naming/VariableNumber:
130
130
  Enabled: false
131
131
 
132
+ # Same rationale as Naming/VariableNumber: the specs deliberately number fixtures
133
+ # (rental_1, metadata_2) when they describe an ordered batch.
134
+ RSpec/IndexedLet:
135
+ Enabled: false
136
+
137
+ # Development dependencies are declared in the gemspec, not in the Gemfile.
138
+ Gemspec/DevelopmentDependencies:
139
+ Enabled: false
140
+
141
+ # `is_expected_block` (spec/support/is_expected_block.rb) is `expect { subject }`,
142
+ # which the cop cannot see through.
143
+ RSpec/NoExpectationExample:
144
+ AllowedPatterns:
145
+ - "^is_expected_block"
146
+
132
147
  Metrics/BlockLength:
133
148
  Max: 30
134
149
  Exclude:
@@ -136,7 +151,7 @@ Metrics/BlockLength:
136
151
  - 'lib/dionysus/support/rspec/outbox_publishable.rb'
137
152
  - 'lib/dionysus/consumer/persistor.rb'
138
153
 
139
- Naming/PredicateName:
154
+ Naming/PredicatePrefix:
140
155
  Enabled: false
141
156
 
142
157
  Metrics/AbcSize:
data/.rubocop_todo.yml CHANGED
@@ -52,3 +52,17 @@ Metrics/CyclomaticComplexity:
52
52
  Metrics/BlockLength:
53
53
  Exclude:
54
54
  - 'lib/dionysus/producer/karafka_responder_generator.rb'
55
+
56
+ # `declared_attributes` and friends are Arrays of [name, options] pairs, not Hashes,
57
+ # so the cop's each_key/each_value suggestion raises NoMethodError.
58
+ Style/HashEachMethods:
59
+ Exclude:
60
+ - 'lib/dionysus/producer/model_serializer.rb'
61
+
62
+ # integration_spec prints progress for a long-running Kafka run; the null provider
63
+ # specs use `puts` as the sentinel proving the block was called.
64
+ RSpec/Output:
65
+ Exclude:
66
+ - 'spec/dionysus/utils/null_retry_provider_spec.rb'
67
+ - 'spec/dionysus/utils/null_transaction_provider_spec.rb'
68
+ - 'spec/integration_spec.rb'
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-3.3.11
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.1.0]
4
+
5
+ ### Fixed
6
+
7
+ - Keep the last published message when removing duplicates from a consumer batch. `RemoveDuplicatesStrategy` kept the message with the highest payload `updated_at` per event and id, but payloads are serialized at publish time, so freshness follows publish order rather than `updated_at`: a payload carrying values computed on read from associated records changes without the record's own `updated_at` changing, and can then arrive with an older `updated_at` than a staler message. Those messages were silently discarded, losing the change in every consumer with no error and no trace. The message with the highest Kafka offset is kept instead - a Karafka batch comes from a single topic-partition, so offsets strictly increase in publish order.
8
+
9
+ ## [1.0.0]
10
+
11
+ Brings `dionysus-rb` back in line with the private gem it was extracted from, which had moved on
12
+ by four releases.
13
+
14
+ ### Breaking changes
15
+
16
+ - Require Ruby >= 3.3 (was >= 2.7).
17
+ - Require karafka `~> 2.4` (was `~> 2.0`). Follow https://karafka.io/docs/Upgrades-2.4/ - most
18
+ notably, `Karafka::Messages::Metadata` no longer carries a single `deserializer`, it carries a
19
+ `deserializers` config with separate `payload` and `key` entries, and `key` is now `raw_key`.
20
+ This only affects code that constructs metadata by hand, such as consumer specs.
21
+ - The instrumenter is now expected to respond to `increment(name, options)` in addition to
22
+ `instrument`. `Dionysus::Utils::NullInstrumenter` implements it; a custom instrumenter that does
23
+ not will raise when a publish produces no message (see below).
24
+
25
+ ### Added
26
+
27
+ - Notify when publishing an outbox record produces no Kafka message at all.
28
+ `Dionysus::Producer::Outbox::Publisher#publish` could complete successfully having sent nothing -
29
+ a model declared only as a `with:` dependency has no top-level responder, so when its parent
30
+ could not be resolved the call returned normally - and `RecordsProcessor` then marked the record
31
+ as published, losing the change in every consumer with no error and no retry. Such a record is
32
+ now logged, counted as `dionysus.publish.nothing_published` and reported through the configured
33
+ `error_handler`. It notifies rather than raises: the producer cannot distinguish a legitimately
34
+ deleted parent from one that is not visible yet.
35
+ - `Dionysus.initialize_application!` accepts `consumer_group_name`, which is used verbatim as the
36
+ consumer group name and takes precedence over `consumer_group_prefix`. `consumer_group_prefix`
37
+ stays supported and unchanged, so the documented migration path from `bookingsync-prometheus`
38
+ keeps working.
39
+
40
+ ### Fixed
41
+
42
+ - Compatibility with the `datadog` gem 2.0, which renamed the `span_type:` keyword of
43
+ `Datadog::Tracing#trace` to `type:`. `Dionysus::Producer::Outbox::DatadogTracer` now picks the
44
+ right keyword depending on whether the pre-2.0 `ddtrace` gem is loaded.
45
+ - Do not publish the `dionysus.consume_batch` event when the batch is empty.
46
+ - `Dionysus::Producer::Outbox::Publisher` no longer trips over a `nil` entry in a parent
47
+ association collection.
48
+
3
49
  ## [0.5.0]
4
50
  - Publish consumed messages batch as `dionysus.consume_batch` event.
5
51
 
data/Gemfile.lock CHANGED
@@ -1,13 +1,13 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dionysus-rb (0.5.0)
4
+ dionysus-rb (1.1.0)
5
5
  activerecord (>= 5)
6
6
  activesupport (>= 3.2)
7
7
  concurrent-ruby
8
8
  dry-monitor
9
9
  file-based-healthcheck
10
- karafka (~> 2.0)
10
+ karafka (~> 2.4)
11
11
  sidekiq
12
12
  sidekiq-cron
13
13
  sigurd
@@ -34,8 +34,8 @@ GEM
34
34
  mutex_m
35
35
  tzinfo (~> 2.0)
36
36
  amq-protocol (2.3.2)
37
- ast (2.4.2)
38
- base64 (0.1.1)
37
+ ast (2.4.3)
38
+ base64 (0.3.0)
39
39
  bigdecimal (3.1.4)
40
40
  bunny (2.22.0)
41
41
  amq-protocol (~> 2.3, >= 2.3.1)
@@ -43,20 +43,20 @@ GEM
43
43
  byebug (11.1.3)
44
44
  carrot-top (0.0.7)
45
45
  json
46
+ cgi (0.5.2)
46
47
  concurrent-ruby (1.2.2)
47
48
  connection_pool (2.4.1)
48
49
  crypt_keeper (0.13.1)
49
50
  activerecord (>= 3.0)
50
51
  activesupport (>= 3.0)
51
- datadog-ci (0.5.1)
52
+ datadog (2.40.0)
53
+ cgi
54
+ datadog-ruby_core_source (~> 3.5, >= 3.5.4)
55
+ libdatadog (~> 36.0.0.1.0)
56
+ libddwaf (~> 1.30.0.0.0)
57
+ logger
52
58
  msgpack
53
- ddtrace (1.18.0)
54
- datadog-ci (~> 0.5.0)
55
- debase-ruby_core_source (= 3.2.3)
56
- libdatadog (~> 5.0.0.1.0)
57
- libddwaf (~> 1.14.0.0.0)
58
- msgpack
59
- debase-ruby_core_source (3.2.3)
59
+ datadog-ruby_core_source (3.5.4)
60
60
  diff-lcs (1.5.0)
61
61
  dogstatsd-ruby (5.5.0)
62
62
  drb (2.1.1)
@@ -118,32 +118,41 @@ GEM
118
118
  i18n (1.14.1)
119
119
  concurrent-ruby (~> 1.0)
120
120
  ice_nine (0.11.2)
121
- json (2.6.2)
122
- karafka (2.2.7)
123
- karafka-core (>= 2.2.2, < 2.3.0)
124
- thor (>= 0.20)
125
- waterdrop (>= 2.6.6, < 3.0.0)
121
+ json (2.21.2)
122
+ karafka (2.5.2)
123
+ base64 (~> 0.2)
124
+ karafka-core (>= 2.5.6, < 2.6.0)
125
+ karafka-rdkafka (>= 0.22.0)
126
+ waterdrop (>= 2.8.9, < 3.0.0)
126
127
  zeitwerk (~> 2.3)
127
- karafka-core (2.2.2)
128
- concurrent-ruby (>= 1.1)
129
- karafka-rdkafka (>= 0.13.1, < 0.14.0)
130
- karafka-rdkafka (0.13.5)
128
+ karafka-core (2.5.13)
129
+ karafka-rdkafka (>= 0.20.0)
130
+ logger (>= 1.6.0)
131
+ karafka-rdkafka (0.22.2)
131
132
  ffi (~> 1.15)
133
+ json (> 2.0)
134
+ logger
132
135
  mini_portile2 (~> 2.6)
133
136
  rake (> 12)
134
- libdatadog (5.0.0.1.0)
135
- libddwaf (1.14.0.0.0)
137
+ language_server-protocol (3.17.0.6)
138
+ libdatadog (36.0.0.1.0)
139
+ libddwaf (1.30.0.0.2)
136
140
  ffi (~> 1.0)
141
+ lint_roller (1.1.0)
142
+ logger (1.7.0)
137
143
  mini_portile2 (2.8.4)
138
144
  minitest (5.20.0)
139
- msgpack (1.7.2)
145
+ msgpack (1.8.4)
140
146
  multi_json (1.15.0)
141
147
  mutex_m (0.1.2)
142
- parallel (1.22.1)
143
- parser (3.1.2.1)
148
+ parallel (2.1.0)
149
+ parser (3.3.12.0)
144
150
  ast (~> 2.4.1)
151
+ racc
145
152
  pg (1.3.5)
153
+ prism (1.9.0)
146
154
  raabro (1.4.0)
155
+ racc (1.8.1)
147
156
  rack (2.2.4)
148
157
  rainbow (3.1.1)
149
158
  rake (13.0.6)
@@ -153,10 +162,9 @@ GEM
153
162
  redis (>= 3.0.4)
154
163
  redlock (1.2.2)
155
164
  redis (>= 3.0.0, < 5.0)
156
- regexp_parser (2.5.0)
165
+ regexp_parser (2.12.0)
157
166
  request_store (1.5.1)
158
167
  rack (>= 1.4)
159
- rexml (3.2.5)
160
168
  rspec (3.12.0)
161
169
  rspec-core (~> 3.12.0)
162
170
  rspec-expectations (~> 3.12.0)
@@ -175,26 +183,32 @@ GEM
175
183
  rspec-core (~> 3.0, >= 3.0.0)
176
184
  sidekiq (>= 2.4.0)
177
185
  rspec-support (3.12.1)
178
- rubocop (1.35.0)
186
+ rubocop (1.89.0)
179
187
  json (~> 2.3)
180
- parallel (~> 1.10)
181
- parser (>= 3.1.2.1)
188
+ language_server-protocol (~> 3.17.0.2)
189
+ lint_roller (~> 1.1.0)
190
+ parallel (>= 1.10)
191
+ parser (>= 3.3.0.2)
182
192
  rainbow (>= 2.2.2, < 4.0)
183
- regexp_parser (>= 1.8, < 3.0)
184
- rexml (>= 3.2.5, < 4.0)
185
- rubocop-ast (>= 1.20.1, < 2.0)
193
+ regexp_parser (>= 2.9.3, < 3.0)
194
+ rubocop-ast (>= 1.49.0, < 2.0)
186
195
  ruby-progressbar (~> 1.7)
187
- unicode-display_width (>= 1.4.0, < 3.0)
188
- rubocop-ast (1.21.0)
189
- parser (>= 3.1.1.0)
190
- rubocop-performance (1.14.3)
191
- rubocop (>= 1.7.0, < 2.0)
192
- rubocop-ast (>= 0.4.0)
193
- rubocop-rake (0.6.0)
194
- rubocop (~> 1.0)
195
- rubocop-rspec (2.12.1)
196
- rubocop (~> 1.31)
197
- ruby-progressbar (1.11.0)
196
+ unicode-display_width (>= 2.4.0, < 4.0)
197
+ rubocop-ast (1.50.0)
198
+ parser (>= 3.3.7.2)
199
+ prism (~> 1.7)
200
+ rubocop-performance (1.26.1)
201
+ lint_roller (~> 1.1)
202
+ rubocop (>= 1.75.0, < 2.0)
203
+ rubocop-ast (>= 1.47.1, < 2.0)
204
+ rubocop-rake (0.7.1)
205
+ lint_roller (~> 1.1)
206
+ rubocop (>= 1.72.1)
207
+ rubocop-rspec (3.10.2)
208
+ lint_roller (~> 1.1)
209
+ regexp_parser (>= 2.0)
210
+ rubocop (~> 1.86, >= 1.86.2)
211
+ ruby-progressbar (1.13.0)
198
212
  ruby2_keywords (0.0.5)
199
213
  sentry-ruby (5.4.1)
200
214
  concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -215,14 +229,16 @@ GEM
215
229
  sorted_set (1.0.3)
216
230
  rbtree
217
231
  set (~> 1.0)
218
- thor (1.2.2)
219
232
  timecop (0.9.5)
220
233
  timeout (0.4.0)
221
234
  tzinfo (2.0.6)
222
235
  concurrent-ruby (~> 1.0)
223
- unicode-display_width (2.2.0)
224
- waterdrop (2.6.7)
225
- karafka-core (>= 2.1.1, < 3.0.0)
236
+ unicode-display_width (3.2.0)
237
+ unicode-emoji (~> 4.1)
238
+ unicode-emoji (4.2.0)
239
+ waterdrop (2.8.13)
240
+ karafka-core (>= 2.4.9, < 3.0.0)
241
+ karafka-rdkafka (>= 0.20.0)
226
242
  zeitwerk (~> 2.3)
227
243
  zeitwerk (2.6.12)
228
244
 
@@ -234,7 +250,7 @@ DEPENDENCIES
234
250
  bundler
235
251
  byebug
236
252
  crypt_keeper
237
- ddtrace
253
+ datadog
238
254
  dionysus-rb!
239
255
  dogstatsd-ruby
240
256
  hermes-rb
data/README.md CHANGED
@@ -62,7 +62,19 @@ Dionysus.initialize_application!(
62
62
  )
63
63
  ```
64
64
 
65
- By default, the name of the consumer grpup will be "NAME_OF_THE_APP_dionysus_consumer_group_for_NAME_OF_THE_APP" where `dionysus_consumer_group_for` is a `consumer_group_prefix`.
65
+ By default, the name of the consumer group will be `"dionysus_consumer_group_for_NAME_OF_THE_APP"`, where `dionysus_consumer_group_for` is the `consumer_group_prefix`.
66
+
67
+ If a prefix is not enough and you need to control the entire name, provide `consumer_group_name`, which is used verbatim and takes precedence over `consumer_group_prefix`:
68
+
69
+ ``` rb
70
+ Dionysus.initialize_application!(
71
+ environment: ENV["RAILS_ENV"],
72
+ seed_brokers: ENV.fetch("DIONYSUS_SEED_BROKER").split(";"),
73
+ client_id: "NAME_OF_THE_APP",
74
+ logger: Rails.logger,
75
+ consumer_group_name: "your_custom_consumer_group_name"
76
+ )
77
+ ```
66
78
 
67
79
 
68
80
  And define `dionysus.rb` initializer with your Kafka topics:
@@ -234,9 +246,16 @@ class Dionysus::Utils::NullInstrumenter
234
246
  def self.instrument(name, payload = {})
235
247
  yield
236
248
  end
249
+
250
+ def self.increment(name, options = {})
251
+ end
237
252
  end
238
253
  ```
239
254
 
255
+ `increment` is used for counters that are not tied to a block, currently only
256
+ `dionysus.publish.nothing_published` (see the Transactional Outbox Pattern section). A custom
257
+ instrumenter that does not implement it will raise when that counter is emitted.
258
+
240
259
 
241
260
  Event Bus is useful if you want to react to some events, with the following interface ((this is the default class)):
242
261
 
@@ -508,6 +527,24 @@ bundle exec outbox_worker_health_check
508
527
 
509
528
  It works for both readiness and liveness checks.
510
529
 
530
+ ##### Publishes that produce no Kafka message
531
+
532
+ `Dionysus::Producer::Outbox::Publisher#publish` can legitimately match no responder at all - a
533
+ model declared only as a `with:` dependency has no top-level responder of its own, so when its
534
+ parent cannot be resolved nothing gets sent. The outbox record would still be stamped as
535
+ published, which means the change is silently lost in every consumer.
536
+
537
+ Such a record is now logged via `Dionysus.logger.error`, reported through the configured
538
+ `error_handler` with `capture_message`, and counted as:
539
+
540
+ ```
541
+ instrumenter.increment("dionysus.publish.nothing_published", tags: ["resource:<class>", "topic:<topic>"])
542
+ ```
543
+
544
+ It notifies rather than raises, because the producer cannot tell a legitimately deleted parent
545
+ from one that is merely not visible yet, and raising on the former would retry forever. Reprocess
546
+ the reported record from the console.
547
+
511
548
  #### Tombstoning records
512
549
 
513
550
  The only way to get rid of messages under a given key from Kafka is to tombstone them. Use `Dionysus::Producer::Outbox::TombstonePublisher` to do it:
@@ -677,7 +714,19 @@ Dionysus.initialize_application!(
677
714
  )
678
715
  ```
679
716
 
680
- By default, the name of the consumer grpup will be "NAME_OF_THE_APP_dionysus_consumer_group_for_NAME_OF_THE_APP" where `dionysus_consumer_group_for` is a `consumer_group_prefix`.
717
+ By default, the name of the consumer group will be `"dionysus_consumer_group_for_NAME_OF_THE_APP"`, where `dionysus_consumer_group_for` is the `consumer_group_prefix`.
718
+
719
+ If a prefix is not enough and you need to control the entire name, provide `consumer_group_name`, which is used verbatim and takes precedence over `consumer_group_prefix`:
720
+
721
+ ``` rb
722
+ Dionysus.initialize_application!(
723
+ environment: ENV["RAILS_ENV"],
724
+ seed_brokers: ENV.fetch("DIONYSUS_SEED_BROKER").split(";"),
725
+ client_id: "NAME_OF_THE_APP",
726
+ logger: Rails.logger,
727
+ consumer_group_name: "your_custom_consumer_group_name"
728
+ )
729
+ ```
681
730
 
682
731
 
683
732
  And define `dionysus.rb` initializer:
data/dionysus-rb.gemspec CHANGED
@@ -30,7 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.add_development_dependency "bundler"
31
31
  spec.add_development_dependency "byebug"
32
32
  spec.add_development_dependency "crypt_keeper"
33
- spec.add_development_dependency "ddtrace"
33
+ spec.add_development_dependency "datadog"
34
34
  spec.add_development_dependency "dogstatsd-ruby"
35
35
  spec.add_development_dependency "hermes-rb"
36
36
  spec.add_development_dependency "pg"
@@ -52,13 +52,13 @@ Gem::Specification.new do |spec|
52
52
  spec.add_dependency "concurrent-ruby"
53
53
  spec.add_dependency "dry-monitor"
54
54
  spec.add_dependency "file-based-healthcheck"
55
- spec.add_dependency "karafka", "~> 2.0"
55
+ spec.add_dependency "karafka", "~> 2.4"
56
56
  spec.add_dependency "sidekiq"
57
57
  spec.add_dependency "sidekiq-cron"
58
58
  spec.add_dependency "sigurd"
59
59
  spec.add_dependency "waterdrop", "~> 2.0"
60
60
  spec.add_dependency "zeitwerk"
61
61
 
62
- spec.required_ruby_version = ">= 2.7"
62
+ spec.required_ruby_version = ">= 3.3"
63
63
  spec.metadata["rubygems_mfa_required"] = "true"
64
64
  end
@@ -30,7 +30,7 @@ class Dionysus::Consumer::BatchEventsPublisher
30
30
  end
31
31
 
32
32
  def publish_events_batch_via_event_bus(events_batch_data)
33
- config.event_bus.publish("dionysus.consume_batch", events_batch_data)
33
+ config.event_bus.publish("dionysus.consume_batch", events_batch_data) if events_batch_data.any?
34
34
  events_batch_data
35
35
  end
36
36
 
@@ -57,13 +57,13 @@ class Dionysus::Consumer::ParamsBatchProcessor
57
57
  message_filters.find { |f| f.ignore_message?(topic: topic, message: message, transformed_data: transformed_data) }
58
58
  end
59
59
 
60
- def instrument(label, options = {}, &block)
61
- config.instrumenter.instrument(label, options, &block)
60
+ def instrument(label, options = {}, &)
61
+ config.instrumenter.instrument(label, options, &)
62
62
  end
63
63
 
64
- def with_mutex(metadata, config, &block)
64
+ def with_mutex(metadata, config, &)
65
65
  message_key = metadata.key || SecureRandom.uuid
66
66
  config.processing_mutex_provider.send(config.processing_mutex_method_name, "Dionysus-#{message_key}",
67
- &block)
67
+ &)
68
68
  end
69
69
  end
@@ -10,18 +10,16 @@ class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
10
10
  private
11
11
 
12
12
  # the idea is following:
13
- # 1. group messages by event and id - for a given model we can expect unique messages for _created and _deleted ecents
13
+ # 1. group messages by event and id - for a given model we can expect unique messages for _created and _deleted events
14
14
  # but we can have multiple _updated events, so this is where we are interested in removing duplicates
15
- # 2. sort each group by updated_at, reverse (because the sort is ascending) and take the first one -
16
- # this way we will have the most recent update
15
+ # 2. from each group keep the message published last (highest offset) - payloads are serialized at
16
+ # publish time, so that is the freshest state; "updated_at" does not track it
17
17
  # 3. flatten the array of arrays as all groups will have a single item
18
- # It is safer to apply sorting just for the _updated events to a given model as otherwise we could change the order of
19
- # the messages for a different type which might be not desirable
20
18
  def transform_messages_array(params_batch)
21
19
  params_batch
22
20
  .to_a
23
21
  .group_by { |batch| grouping_key_by_event_and_id(batch) }
24
- .map { |_, group| group.max_by { |batch| updated_at_from_batch(batch) } }
22
+ .map { |_, group| group.max_by(&:offset) }
25
23
  .flatten
26
24
  end
27
25
 
@@ -32,14 +30,6 @@ class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
32
30
  ].join
33
31
  end
34
32
 
35
- def updated_at_from_batch(batch)
36
- timestamp = batch.payload.fetch("message", []).first.to_h.fetch("data", []).first.to_h.fetch("updated_at", nil)
37
-
38
- return timestamp.to_datetime if timestamp.respond_to?(:to_datetime)
39
-
40
- Time.current
41
- end
42
-
43
33
  def duplicates_removal_not_applicable?(params_batch)
44
34
  any_message_containing_more_than_one_event?(params_batch) || any_event_containing_more_than_one_item(params_batch)
45
35
  end
@@ -151,7 +151,7 @@ class Dionysus::Consumer::Persistor
151
151
  end
152
152
  end
153
153
 
154
- def instrument(label, options = {}, &block)
155
- config.instrumenter.instrument(label, options, &block)
154
+ def instrument(label, options = {}, &)
155
+ config.instrumenter.instrument(label, options, &)
156
156
  end
157
157
  end
@@ -8,9 +8,9 @@ class Dionysus::Consumer::Registry
8
8
  @container = {}
9
9
  end
10
10
 
11
- def namespace(namespace, &block)
11
+ def namespace(namespace, &)
12
12
  registration = Registration.new(namespace)
13
- registration.instance_eval(&block)
13
+ registration.instance_eval(&)
14
14
  container[namespace] = registration
15
15
  end
16
16
 
@@ -32,8 +32,8 @@ class Dionysus::Consumer::Registry
32
32
  @deserializer_klass = deserializer_klass
33
33
  end
34
34
 
35
- def topic(name, options = {}, &block)
36
- new_topic = Topic.new(namespace, name, deserializer_klass, options, &block)
35
+ def topic(name, options = {}, &)
36
+ new_topic = Topic.new(namespace, name, deserializer_klass, options, &)
37
37
  consumer = Dionysus::Consumer::KarafkaConsumerGenerator.new.generate(
38
38
  Dionysus::Consumer.configuration, new_topic
39
39
  )
@@ -27,7 +27,7 @@ class Dionysus::Consumer::SynchronizableModel < SimpleDelegator
27
27
 
28
28
  def assign_attributes_from_dionysus(attributes)
29
29
  public_send("#{synced_data_attribute}=", attributes)
30
- reverse_mapping = config.attributes_mapping_for_model(model.model_name).to_a.map(&:reverse).to_h
30
+ reverse_mapping = config.attributes_mapping_for_model(model.model_name).to_a.to_h(&:reverse)
31
31
 
32
32
  assignable_attributes = extract_assignable_attributes(attributes)
33
33
  .map { |key, value| apply_mapping(reverse_mapping, key, value) }
@@ -13,9 +13,9 @@ class Dionysus::Consumer
13
13
  configuration.registry
14
14
  end
15
15
 
16
- def self.declare(&config)
16
+ def self.declare(&)
17
17
  registry = Dionysus::Consumer::Registry.new
18
- registry.instance_eval(&config)
18
+ registry.instance_eval(&)
19
19
 
20
20
  Dionysus.inject_routing!(registry)
21
21
 
@@ -7,7 +7,7 @@ class Dionysus::Producer::BaseResponder
7
7
 
8
8
  def topic(topic_name)
9
9
  self.topics ||= {}
10
- self.topics[topic_name] = topic_name.to_s
10
+ topics[topic_name] = topic_name.to_s
11
11
  end
12
12
 
13
13
  def call(*data)
@@ -9,7 +9,8 @@ class Dionysus::Producer::Outbox::DatadogTracer
9
9
  end
10
10
 
11
11
  def trace(event_name, topic)
12
- tracer.trace(event_name, span_type: "worker", service: self.class.service_name, on_error: error_handler) do |span|
12
+ tracer.trace(event_name, span_type_key => "worker", service: self.class.service_name,
13
+ on_error: error_handler) do |span|
13
14
  span.set_tag("topic", topic)
14
15
 
15
16
  yield
@@ -26,6 +27,16 @@ class Dionysus::Producer::Outbox::DatadogTracer
26
27
  end
27
28
  end
28
29
 
30
+ # datadog 2.0 renamed the `span_type:` keyword of Datadog::Tracing#trace to `type:`.
31
+ # DDTrace is only defined by the pre-2.0 ddtrace gem.
32
+ def span_type_key
33
+ if defined?(DDTrace)
34
+ :span_type
35
+ else
36
+ :type
37
+ end
38
+ end
39
+
29
40
  def error_handler
30
41
  ->(span, error) { span.set_error(error) }
31
42
  end
@@ -78,7 +78,7 @@ module Dionysus::Producer::Outbox::Model
78
78
  end
79
79
 
80
80
  def error
81
- if error_class_arity == 1 || error_class_arity == -1
81
+ if [1, -1].include?(error_class_arity)
82
82
  error_class.constantize.new(error_message)
83
83
  else
84
84
  StandardError.new("#{error_class_constant}: #{error_message}")
@@ -74,8 +74,7 @@ class Dionysus::Producer::Outbox::Publishable < SimpleDelegator
74
74
  .map(&:primary_topic)
75
75
  topics_from_dependencies = Dionysus::Producer
76
76
  .responders_for_dependency_parent(model.class)
77
- .map(&:last)
78
- .map(&:primary_topic)
77
+ .map { |dependency| dependency.last.primary_topic }
79
78
 
80
79
  [top_level_topics, topics_from_dependencies]
81
80
  .flatten
@@ -33,8 +33,10 @@ class Dionysus::Producer::Outbox::Publisher
33
33
  return
34
34
  end
35
35
 
36
- publish_for_top_level_resource(outbox_record, resource, event_name, topic, options)
37
- publish_for_dependency(resource, topic, options)
36
+ published_count = publish_for_top_level_resource(outbox_record, resource, event_name, topic, options) +
37
+ publish_for_dependency(resource, topic, options)
38
+ handle_nothing_published(resource, event_name, topic) if published_count.zero?
39
+ published_count
38
40
  end
39
41
  end
40
42
 
@@ -67,19 +69,20 @@ class Dionysus::Producer::Outbox::Publisher
67
69
  delegate :logger, to: Dionysus
68
70
 
69
71
  def publish_for_top_level_resource(outbox_record, resource, event_name, topic, options)
70
- Dionysus::Producer.responders_for_model_for_topic(resource.class, topic).each do |responder|
72
+ Dionysus::Producer.responders_for_model_for_topic(resource.class, topic).sum do |responder|
71
73
  partition_key = outbox_record.partition_key.presence || Dionysus::Producer::PartitionKey.new(
72
74
  resource
73
75
  ).to_key(responder: responder)
74
76
  key = Dionysus::Producer::Key.new(resource).to_key
75
77
 
76
78
  responder.call(generate_message(event_name, resource), options.merge(partition_key: partition_key, key: key))
79
+ 1
77
80
  end
78
81
  end
79
82
 
80
83
  def publish_for_dependency(resource, topic, options)
81
84
  Dionysus::Producer.responders_for_dependency_parent_for_topic(resource.class,
82
- topic).each do |parent_klass, responder|
85
+ topic).sum do |parent_klass, responder|
83
86
  parent_event_name = Dionysus::Producer::Outbox::EventName.new(
84
87
  parent_klass.model_name.singular
85
88
  ).updated
@@ -89,10 +92,11 @@ class Dionysus::Producer::Outbox::Publisher
89
92
  elsif resource.class.reflect_on_association(parent_klass.model_name.plural)
90
93
  parent_records = resource.public_send(parent_klass.model_name.plural)
91
94
  else
92
- next
95
+ next 0
93
96
  end
94
97
 
95
- example_parent_record = parent_records.first or next
98
+ parent_records = parent_records.to_a.compact
99
+ example_parent_record = parent_records.first or next 0
96
100
  partition_key = Dionysus::Producer::PartitionKey.new(example_parent_record, config: config)
97
101
  .to_key(responder: responder)
98
102
  key = Dionysus::Producer::Key.new(example_parent_record).to_key
@@ -101,9 +105,22 @@ class Dionysus::Producer::Outbox::Publisher
101
105
  responder.call(generate_message(parent_event_name, parent_record),
102
106
  options.merge(partition_key: partition_key, key: key))
103
107
  end
108
+ parent_records.size
104
109
  end
105
110
  end
106
111
 
112
+ # Deliberately does not raise: a legitimately deleted parent is indistinguishable from
113
+ # one that is not visible yet, and raising on the former would retry forever.
114
+ def handle_nothing_published(resource, event_name, topic)
115
+ message = "Published nothing for #{resource.class}, id: #{resource.id}, event: #{event_name}, " \
116
+ "topic: #{topic} - no responder matched and no parent could be resolved. " \
117
+ "Make sure it's processed later (e.g. by directly doing it from console)."
118
+ logger.error(message)
119
+ instrumenter.increment("dionysus.publish.nothing_published",
120
+ tags: ["resource:#{resource.class}", "topic:#{topic}"])
121
+ error_handler.capture_message(message)
122
+ end
123
+
107
124
  def publish_observer(observer_record, responder)
108
125
  publishable = Dionysus::Producer::Outbox::Publishable.new(observer_record)
109
126
  partition_key = Dionysus::Producer::PartitionKey.new(publishable).to_key(responder: responder)
@@ -8,9 +8,9 @@ class Dionysus::Producer::Registry
8
8
  @container = {}
9
9
  end
10
10
 
11
- def namespace(namespace, &block)
11
+ def namespace(namespace, &)
12
12
  registration = Registration.new(namespace)
13
- registration.instance_eval(&block)
13
+ registration.instance_eval(&)
14
14
  container[namespace] = registration
15
15
  end
16
16
 
@@ -32,9 +32,9 @@ class Dionysus::Producer::Registry
32
32
  @serializer_klass = serializer_klass
33
33
  end
34
34
 
35
- def topic(name, options = {}, &block)
35
+ def topic(name, options = {}, &)
36
36
  new_topic = Topic.new(namespace, name, serializer_klass, options)
37
- new_topic.instance_eval(&block)
37
+ new_topic.instance_eval(&)
38
38
  producer = Dionysus::Producer::KarafkaResponderGenerator.new.generate(
39
39
  Dionysus::Producer.configuration, new_topic
40
40
  )
@@ -119,7 +119,7 @@ class Dionysus::Producer::Registry
119
119
  association_name = observer_config.fetch(:association_name)
120
120
  _attributes = observer_config.fetch(:attributes)
121
121
 
122
- if association_name.is_a?(Symbol) && !model.instance_methods.include?(association_name.to_sym)
122
+ if association_name.is_a?(Symbol) && !model.method_defined?(association_name.to_sym)
123
123
  raise ArgumentError.new("association name :#{association_name} does not exist on model #{model}")
124
124
  end
125
125
  end
@@ -156,7 +156,7 @@ class Dionysus::Producer::Registry
156
156
  config_model_name = observer_config.fetch(:model).to_s
157
157
  config_attributes = observer_config.fetch(:attributes).to_a.map(&:to_sym)
158
158
 
159
- config_model_name == resource_model_name && (config_attributes & changeset_attributes).any?
159
+ config_model_name == resource_model_name && config_attributes.intersect?(changeset_attributes)
160
160
  end
161
161
  end
162
162
  end
@@ -13,10 +13,10 @@ class Dionysus::Producer
13
13
  configuration.registry
14
14
  end
15
15
 
16
- def self.declare(&config)
16
+ def self.declare(&)
17
17
  registry = Dionysus::Producer::Registry.new
18
18
 
19
- registry.instance_eval(&config)
19
+ registry.instance_eval(&)
20
20
  configure do |configuration|
21
21
  configuration.registry = registry
22
22
  end
@@ -4,4 +4,6 @@ class Dionysus::Utils::NullInstrumenter
4
4
  def self.instrument(_name, _payload = {})
5
5
  yield
6
6
  end
7
+
8
+ def self.increment(_name, _options = {}); end
7
9
  end
@@ -3,5 +3,5 @@
3
3
  module Dionysus
4
4
  module Version
5
5
  end
6
- VERSION = "0.5.0"
6
+ VERSION = "1.1.0"
7
7
  end
data/lib/dionysus.rb CHANGED
@@ -20,7 +20,7 @@ module Dionysus
20
20
  end
21
21
  end
22
22
 
23
- def self.initialize_application!(environment:, seed_brokers:, client_id:, logger:, draw_routing: true, consumer_group_prefix: CONSUMER_GROUP_PREFIX)
23
+ def self.initialize_application!(environment:, seed_brokers:, client_id:, logger:, draw_routing: true, consumer_group_prefix: CONSUMER_GROUP_PREFIX, consumer_group_name: nil)
24
24
  ENV["KARAFKA_ENV"] = environment
25
25
 
26
26
  karafka_app = Class.new(Karafka::App) do
@@ -37,7 +37,10 @@ module Dionysus
37
37
 
38
38
  Object.const_set(:KarafkaApp, karafka_app)
39
39
  self.karafka_application = karafka_app
40
- evaluate_routing(consumer_group_prefix: consumer_group_prefix) if consumer_registry.present? && draw_routing
40
+ return unless consumer_registry.present? && draw_routing
41
+
42
+ consumer_group_name ||= "#{consumer_group_prefix}_#{karafka_application.config.client_id}"
43
+ evaluate_routing(consumer_group_name: consumer_group_name)
41
44
  end
42
45
 
43
46
  def self.karafka_application=(karafka_app)
@@ -110,12 +113,11 @@ module Dionysus
110
113
  @monitor ||= Dionysus::Monitor.new
111
114
  end
112
115
 
113
- def self.evaluate_routing(consumer_group_prefix:)
114
- consumer_group_name = "#{consumer_group_prefix}_#{karafka_application.config.client_id}"
116
+ def self.evaluate_routing(consumer_group_name:)
115
117
  karafka_application.instance_exec(consumer_registry) do |registry|
116
118
  consumer_groups.draw do
117
119
  consumer_group consumer_group_name do
118
- registry.registrations.each do |_, registration|
120
+ registry.registrations.each_value do |registration|
119
121
  registration.topics.each do |topic|
120
122
  topic topic.to_s do
121
123
  consumer topic.consumer
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dionysus-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Galanciak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-15 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -67,7 +67,7 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: ddtrace
70
+ name: datadog
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
@@ -366,14 +366,14 @@ dependencies:
366
366
  requirements:
367
367
  - - "~>"
368
368
  - !ruby/object:Gem::Version
369
- version: '2.0'
369
+ version: '2.4'
370
370
  type: :runtime
371
371
  prerelease: false
372
372
  version_requirements: !ruby/object:Gem::Requirement
373
373
  requirements:
374
374
  - - "~>"
375
375
  - !ruby/object:Gem::Version
376
- version: '2.0'
376
+ version: '2.4'
377
377
  - !ruby/object:Gem::Dependency
378
378
  name: sidekiq
379
379
  requirement: !ruby/object:Gem::Requirement
@@ -461,6 +461,7 @@ files:
461
461
  - ".rspec"
462
462
  - ".rubocop.yml"
463
463
  - ".rubocop_todo.yml"
464
+ - ".ruby-version"
464
465
  - CHANGELOG.md
465
466
  - Gemfile
466
467
  - Gemfile.lock
@@ -571,14 +572,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
571
572
  requirements:
572
573
  - - ">="
573
574
  - !ruby/object:Gem::Version
574
- version: '2.7'
575
+ version: '3.3'
575
576
  required_rubygems_version: !ruby/object:Gem::Requirement
576
577
  requirements:
577
578
  - - ">="
578
579
  - !ruby/object:Gem::Version
579
580
  version: '0'
580
581
  requirements: []
581
- rubygems_version: 3.4.10
582
+ rubygems_version: 3.5.22
582
583
  signing_key:
583
584
  specification_version: 4
584
585
  summary: A framework on top of Karafka for Change Data Capture on the model level.