dionysus-rb 1.0.0 → 1.2.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: 0c0362ffd0074956ac7ace1f3c4a392eaa7b5b09a79ecf113a14f9da8f9f5b22
4
- data.tar.gz: 44cdab85d128017c09dc0535c1225d9d68515addc5c2d1d4b28b50dbb2fc0150
3
+ metadata.gz: 715bda096eaf87ea615134206607a11d22ff3d673b5cbe9625c584599fb5696b
4
+ data.tar.gz: d4c5741f509d02f0c5a28a4332f34b08b28f46ca1c7d026d59f59bff2e37638f
5
5
  SHA512:
6
- metadata.gz: 63905f2eb238cdca13e750e728498449d6238be26f30bbe68c4709c61218c989d33dd8c7da553c4c4ec6e36dc6b05cc84a8cc44067048752b88018338d1809ee
7
- data.tar.gz: cabf8edf52bd2ba5ffaf26608110a7f110ba4a7c15ff2ead082c74cd780c5018bd92b0b44a15e847edcf65c39a898bdd537f2c9c85c3c191c1a1803c1a657e96
6
+ metadata.gz: c101ce0cfa0f3ddb58339bdf28b3b479838fb3b66107d8223d4a795888e5048cc8f044db232fe7ae03899cf558d1b8100a8119119d8a8d3faac3996082671618
7
+ data.tar.gz: be3c63dddbc4b1c3ae58134b050370c62a0940d3227ca3e1820cce72a1bba36cb932de2906d42a7fe723f811ac30dbc4431f668c5832787fd0a27fb521fc5594
data/.rubocop.yml CHANGED
@@ -170,6 +170,7 @@ Naming/AccessorMethodName:
170
170
  RSpec/DescribeClass:
171
171
  Exclude:
172
172
  - 'spec/integration_spec.rb'
173
+ - 'spec/dedup_serialization_time_integration_spec.rb'
173
174
 
174
175
  Style/MultilineBlockChain:
175
176
  Exclude:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0]
4
+
5
+ ### Fixed
6
+
7
+ - Rank duplicate messages in a consumer batch by when their payload was serialized, not by Kafka offset. The previous release ranked on the highest offset, on the premise that offsets strictly increase in publish order. They do - but publish order is not serialization order: `Publisher#publish` does `find_by` -> `generate_message` -> `responder.call`, so a message produced later can carry an earlier snapshot. Measured in production on one record, eight `updated` events inside 700 ms: the offset the batch ended on was published at `.905` carrying a payload read at `.398194` with `paid_amount 0.0`, while an earlier offset published at `.837` carried a payload read at `.671830` with the real value of `1447.0`. Ranking on offset kept the stale payload and the consumer's value stuck at `0.00` with nothing to self-heal it.
8
+ - Neither field already in the message is a freshness signal. `updated_at` is the record's own timestamp and does not move when only an associated record changes, so two payloads with different computed values can share one `updated_at` - that is the tie the previous release was written for. The offset is publish order, per above. The two known incidents are mirror images: in one the correct message has the higher offset and the older `updated_at`, in the other the lower offset and the newer `updated_at`, so no ordering over the existing fields fixes both.
9
+ - The producer therefore stamps `serialized_at` where the payload is actually built, and the consumer ranks on `[serialized_at, offset]`. Messages without the field fall back to `Time.at(0)` and are ordered among themselves by offset, which is exactly the previous behaviour; a stamped message wins over an unstamped one, which is correct because a stamp can only come from a newer producer.
10
+ - The stamp is gated on `config.include_serialized_at_in_payload`, default `false`, so published envelopes are unchanged until it is switched on. Roll consumers out first - with the fallback that deploy is a no-op - then enable it on the producer.
11
+
12
+ ## [1.1.0]
13
+
14
+ ### Fixed
15
+
16
+ - 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.
17
+
3
18
  ## [1.0.0]
4
19
 
5
20
  Brings `dionysus-rb` back in line with the private gem it was extracted from, which had moved on
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dionysus-rb (1.0.0)
4
+ dionysus-rb (1.2.0)
5
5
  activerecord (>= 5)
6
6
  activesupport (>= 3.2)
7
7
  concurrent-ruby
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
6
+ SERIALIZED_AT_FORMAT = %r{\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z\z}
7
+
4
8
  def call(params_batch)
5
9
  return params_batch if duplicates_removal_not_applicable?(params_batch)
6
10
 
@@ -10,21 +14,39 @@ class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
10
14
  private
11
15
 
12
16
  # 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
17
+ # 1. group messages by event and id - for a given model we can expect unique messages for _created and _deleted events
14
18
  # 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
19
+ # 2. from each group keep the message whose payload was serialized last, with the offset as a tie-break -
20
+ # the offset alone is publish order, and a message published later can carry an earlier snapshot
17
21
  # 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
22
  def transform_messages_array(params_batch)
21
23
  params_batch
22
24
  .to_a
23
25
  .group_by { |batch| grouping_key_by_event_and_id(batch) }
24
- .map { |_, group| group.max_by { |batch| updated_at_from_batch(batch) } }
26
+ .map { |_, group| freshest(group) }
25
27
  .flatten
26
28
  end
27
29
 
30
+ # a group is ranked by its stamps only when every message in it carries one - during the
31
+ # producer rollout a batch can hold both, and a stamp says nothing about an unstamped neighbour
32
+ def freshest(group)
33
+ stamps = group.map { |message| serialized_at_for(message) }
34
+
35
+ return group.max_by(&:offset) if stamps.any?(&:nil?)
36
+
37
+ group.zip(stamps).max_by { |message, serialized_at| [serialized_at, message.offset] }.first
38
+ end
39
+
40
+ def serialized_at_for(message)
41
+ raw = message.payload.fetch("message").first["serialized_at"]
42
+
43
+ return unless raw.is_a?(String) && raw.match?(SERIALIZED_AT_FORMAT)
44
+
45
+ Time.parse(raw)
46
+ rescue
47
+ nil
48
+ end
49
+
28
50
  def grouping_key_by_event_and_id(batch)
29
51
  [
30
52
  batch.payload.fetch("message").first.fetch("event"),
@@ -32,14 +54,6 @@ class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
32
54
  ].join
33
55
  end
34
56
 
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
57
  def duplicates_removal_not_applicable?(params_batch)
44
58
  any_message_containing_more_than_one_event?(params_batch) || any_event_containing_more_than_one_item(params_batch)
45
59
  end
@@ -8,7 +8,8 @@ class Dionysus::Producer::Config
8
8
  :lock_client, :lock_expiry_time, :error_handler, :outbox_publishing_batch_size,
9
9
  :transactional_outbox_enabled, :sidekiq_queue, :publisher_service_name,
10
10
  :genesis_consistency_safety_delay, :hermes_event_producer, :publish_after_commit, :outbox_worker_publishing_delay,
11
- :high_priority_sidekiq_queue, :observers_inline_maximum_size, :remove_consecutive_duplicates_before_publishing
11
+ :high_priority_sidekiq_queue, :observers_inline_maximum_size, :remove_consecutive_duplicates_before_publishing,
12
+ :include_serialized_at_in_payload
12
13
 
13
14
  def self.default_sidekiq_queue
14
15
  :dionysus
@@ -96,6 +97,14 @@ class Dionysus::Producer::Config
96
97
  @observers_inline_maximum_size || 1000
97
98
  end
98
99
 
100
+ # Off by default so consumers, which fall back to the offset when the field is absent, can be
101
+ # rolled out first.
102
+ def include_serialized_at_in_payload
103
+ return @include_serialized_at_in_payload if defined?(@include_serialized_at_in_payload)
104
+
105
+ false
106
+ end
107
+
99
108
  def remove_consecutive_duplicates_before_publishing
100
109
  return @remove_consecutive_duplicates_before_publishing if defined?(@remove_consecutive_duplicates_before_publishing)
101
110
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/string"
4
+ require "time"
4
5
 
5
6
  class Dionysus::Producer::KarafkaResponderGenerator
6
7
  TOMBSTONE = nil
@@ -45,13 +46,18 @@ class Dionysus::Producer::KarafkaResponderGenerator
45
46
 
46
47
  record = records.sample
47
48
 
49
+ # the offset alone is publish order, and a message published later can carry an
50
+ # earlier snapshot, so consumers need to know when this payload was actually read
51
+ serialized_at = config.include_serialized_at_in_payload ? Time.now.utc : nil
48
52
  payload = serialize_to_payload(records, topic, batch_options)
49
53
 
50
- {
54
+ event_payload = {
51
55
  event: event,
52
56
  model_name: record.model_name.name,
53
57
  data: payload
54
58
  }
59
+ event_payload[:serialized_at] = serialized_at.iso8601(6) if serialized_at
60
+ event_payload
55
61
  end
56
62
  unless genesis_only?(options)
57
63
  respond_to topic_name, { message: message }, **final_options
@@ -3,5 +3,5 @@
3
3
  module Dionysus
4
4
  module Version
5
5
  end
6
- VERSION = "1.0.0"
6
+ VERSION = "1.2.0"
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dionysus-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Galanciak
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activerecord
@@ -563,6 +564,7 @@ metadata:
563
564
  source_code_uri: https://github.com/BookingSync/dionysus-rb
564
565
  changelog_uri: https://github.com/BookingSync/dionysus-rb/blob/master/CHANGELOG.md
565
566
  rubygems_mfa_required: 'true'
567
+ post_install_message:
566
568
  rdoc_options: []
567
569
  require_paths:
568
570
  - lib
@@ -577,7 +579,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
577
579
  - !ruby/object:Gem::Version
578
580
  version: '0'
579
581
  requirements: []
580
- rubygems_version: 4.0.18
582
+ rubygems_version: 3.5.22
583
+ signing_key:
581
584
  specification_version: 4
582
585
  summary: A framework on top of Karafka for Change Data Capture on the model level.
583
586
  test_files: []