dionysus-rb 1.3.0 → 1.4.1

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: 968407d443681c04bfc7426b8925d7f63db69a09763357413d01218c03ec3ad8
4
- data.tar.gz: baf5e739a77bc2d48ab5197591ddb59c6d970bdaed68479014d5afbec5698f0e
3
+ metadata.gz: aea1780da49737ea39a412112502ccefec1921bcaa8e4d9e3f4a17c01e359cb1
4
+ data.tar.gz: 0de793797246fa1aa63209eb9713529df3bf3b51aedca2d03be485ab3b140f43
5
5
  SHA512:
6
- metadata.gz: 121b9c6af2b90700a7af1aba098282cfe05df901c633dfcc71d7845bda935b5e124b21c8bedab20895e9c5dd11a3dd12c2edd049c1bcd9b984c7df0da8a246b5
7
- data.tar.gz: 6594b49acf6f64bb59475bc4a8a3a70c5747a09dcb72d3bc815e544bf3016bf0df732f3b15f0b3b7c8106578ba3c8ed49f4206d5b206e240285d376b6e08b31f
6
+ metadata.gz: 9ba62b20fdf34088c43f28bfd71e4fa721b4d512125ca030c16c61eb077354aab5751a05c1ce0192708a7fc87ccd3d3af0a63b8a2f06a48ab903858c928d4ce8
7
+ data.tar.gz: d67700b960048a4ea6b272d20d193ba4232115665a5a72d0b2171cec6ba089c3bb451ab36fd5092ffbec55a763fca95fa7b8b7299b3f44263122e58aea26932f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.1]
4
+ - Stamp `serialized_at` when the attempt that is actually published begins, not before the retry loop. 1.4.0 takes the stamp once and then re-serializes up to `max_snapshot_attempts` times, so a message can be published a whole retry cycle after its own stamp - and the payload it publishes is the one read last, not the one the stamp describes. Consumers rank duplicates by `[serialized_at, offset]`, so the freshest payload ended up carrying the oldest stamp and losing its group to a staler sibling. That is the failure `serialized_at` was added to prevent, reintroduced by the guard added to prevent a different one.
5
+ - Measured on the affected topic over 65 minutes: 205 of 6,572 messages (3.12%) carried an `updated_at` LATER than their own `serialized_at`, the worst by 1,225 ms. A timestamp cannot describe a moment after the one it was taken at, so that alone proves the stamp preceded the payload.
6
+ - `serialize_consistently` now returns `[payload, read_at]`. The looseness between stamp and payload is bounded by a single serialization pass again, as it was before 1.4.0, rather than by the whole retry cycle - so contended records, which retry most, are no longer the ones stamped most wrongly.
7
+ - Covered end to end against a real broker in `spec/serialized_at_tracks_published_attempt_spec.rb`: a contended publish retries while a competing publish serializes once and stamps later, both are read back off the topic, and the real `RemoveDuplicatesStrategy` is asked which survives. The spec fails on 1.4.0 on both assertions.
8
+
9
+ ## [1.4.0]
10
+
11
+ ### Fixed
12
+
13
+ - Re-serialize a record whose row moved while its payload was being built, behind `config.publish_consistent_snapshots` (default `false`), so the payload describes a single moment.
14
+ - The defect, reproduced against a real broker in `spec/torn_payload_reproduction_spec.rb`. A serializer reads a record's own columns and then queries its associations. Anything committed in between lands in the payload beside a timestamp taken before it, so the payload's `updated_at` can predate the records embedded next to it. The reproduction publishes from a record read fresh moments earlier and still emits a payload tens of milliseconds adrift carrying an association row the timestamp predates - no stale object, no query cache, no clock skew and no replica involved.
15
+ - Why that is a lost record rather than a stale field. Consumers rank duplicates and then compare the payload's `updated_at` against what they have stored. A payload whose timestamp predates its own contents loses that comparison and is skipped by `next`, and the has_many records embedded in it are persisted further down the same loop body, so they are dropped with it: the parent keeps its old values and the child rows are never created, with no error and no counter.
16
+ - Measured on the sibling gem's affected topic in production: serialization spans 95 ms at the median and up to 3.5 s, which is ample time for a write to arrive; 141 of 1,476 records receiving more than one message showed `updated_at` moving backwards, and in 69 of those the message that wins deduplication reported a timestamp older than one already published at a lower offset.
17
+ - The check reads the row's timestamp back with the query cache bypassed. Publishing runs inside the request when `publish_after_commit` is on, where the cache is live on that connection, and a cached read would report that nothing had moved and hand back the very payload the check exists to catch.
18
+ - Retries are bounded by `config.max_snapshot_attempts` (default `Producer::MAX_SNAPSHOT_ATTEMPTS`, 3) and the last attempt is published rather than dropped - a payload that may still be torn beats no message at all. Records without an `updated_at`, and anything that is not an `ActiveRecord::Base`, are serialized once as before.
19
+ - The retry is the expensive half, and its cost runs opposite to its benefit. Measured over 5 events on one record with the serializer held at the production median of 95 ms: a quiet record costs nothing beyond one indexed read per message, but a record written every 40 ms pins to the ceiling on every event - 15 serializations instead of 5, and 1.5 s instead of 0.5 s - and still publishes an attempt that may be torn. The hottest records are both the most expensive to guard and the least likely to converge, so `max_snapshot_attempts` is configurable to bound it.
20
+ - Every guarded message increments `dionysus.publish.consistent_snapshot` exactly once, tagged `result:consistent|exhausted|unsupported`, `attempts:N`, `topic` and `model`. `exhausted` is the case worth alerting on: the payload is published with no marker and consumers see an ordinary message, so the counter is the only place that outcome is ever visible. Nothing is emitted while the feature is off, so an empty series reads as "not running" rather than "running clean".
21
+
3
22
  ## [1.3.0]
4
23
 
5
24
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dionysus-rb (1.3.0)
4
+ dionysus-rb (1.4.1)
5
5
  activerecord (>= 5)
6
6
  activesupport (>= 3.2)
7
7
  concurrent-ruby
data/README.md CHANGED
@@ -337,10 +337,24 @@ Dionysus::Producer.configure do |config|
337
337
  config.outbox_worker_publishing_delay = 5 # non required, defaults to 0 a delay in seconds until the outbox record is considered publishable. Check `Publishing records right after the transaction is committed` section for more details.
338
338
  config.remove_consecutive_duplicates_before_publishing = true # not required, defaults to false. If set to true, the consecutive duplicates in the publishable batch will be removed and only one message will be published to a given topic. For example, if for whatever reason there are ten messages in a row for a given topic to publish `user_updated` ecent, only the last will be published. Check `Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy` for exact implementation. To verify if this feature is useful, it's recommended to browse Karafka UI and check messages in the topics if there are any obvious duplicates happening often.
339
339
  config.observers_inline_maximum_size = 100 # not required, defaults to 1000. This config setting matters in case there is a huge amount of dependent records (observers). If the threshold is exceeded, the observers will be published via Genesis process to not cause issues like blocking the outbox worker.
340
+ config.publish_consistent_snapshots = true # not required, defaults to `false`. A serializer reads a record's own columns and then queries its associations, so a write landing in between produces a payload whose `updated_at` predates the records embedded next to it - consumers rank and guard on that timestamp, so such a payload is discarded along with its embedded associations. When enabled, the row's timestamp is read back (bypassing the query cache) after serializing and the payload is re-serialized if the record moved. Emits the `dionysus.publish.consistent_snapshot` counter described below.
341
+ config.max_snapshot_attempts = 2 # not required, defaults to 3. Only relevant with `publish_consistent_snapshots` enabled. A record written faster than it serializes fails the check on every attempt, so it pays the full serialization cost this many times over and still publishes a payload that may be torn - lower this to bound that cost. Set it to 1 to keep the check and its metric while disabling re-serialization entirely.
340
342
  config.sidekiq_queue = :default # required, defaults to :dionysus. The queue will be used for a genesis process
341
343
  end
342
344
  ```
343
345
 
346
+ ##### Monitoring consistent snapshots
347
+
348
+ With `publish_consistent_snapshots` enabled, every guarded message increments `dionysus.publish.consistent_snapshot` exactly once, tagged with `result`, `attempts`, `topic` and `model`. Nothing is emitted when the feature is off, so an empty series means the guard is not running rather than running clean.
349
+
350
+ | `result` | meaning |
351
+ |---|---|
352
+ | `consistent` | the record had not moved by the time the payload was built. With `attempts:1` the first try was already clean; higher values mean it converged after re-serializing. |
353
+ | `exhausted` | the record was still moving at `max_snapshot_attempts`. The payload is published anyway - a payload that may be torn beats no message - but it carries no marker and consumers see an ordinary message, so this counter is the only place the outcome is visible. |
354
+ | `unsupported` | the records carry no `updated_at`, so the guard could not run on that message at all. |
355
+
356
+ Watch the `exhausted` rate before and after a rollout: the retry cost scales with write rate multiplied by serialization span, so the hottest records are both the most expensive to guard and the least likely to converge.
357
+
344
358
  ##### DionysusOutbox model
345
359
 
346
360
  Generate a model for the outbox:
@@ -9,7 +9,8 @@ class Dionysus::Producer::Config
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
11
  :high_priority_sidekiq_queue, :observers_inline_maximum_size, :remove_consecutive_duplicates_before_publishing,
12
- :include_serialized_at_in_payload, :publish_with_uncached_reads
12
+ :include_serialized_at_in_payload, :publish_with_uncached_reads, :publish_consistent_snapshots,
13
+ :max_snapshot_attempts
13
14
 
14
15
  def self.default_sidekiq_queue
15
16
  :dionysus
@@ -99,12 +100,30 @@ class Dionysus::Producer::Config
99
100
 
100
101
  # Off by default so it can be switched on per producer, and switched back off in one env change
101
102
  # if the extra reads ever cost more than they are worth.
103
+ # How many times a payload may be re-serialized before the last attempt is published as it stands.
104
+ # This is the expensive half of publish_consistent_snapshots: a record written faster than it
105
+ # serializes fails the check on every attempt, so it pays the full serialization cost this many
106
+ # times over and still publishes a payload that may be torn. Lower it to bound that cost; 1
107
+ # disables re-serialization while leaving the check (and its metric) in place.
108
+ def max_snapshot_attempts
109
+ @max_snapshot_attempts || Dionysus::Producer::MAX_SNAPSHOT_ATTEMPTS
110
+ end
111
+
102
112
  def publish_with_uncached_reads
103
113
  return @publish_with_uncached_reads if defined?(@publish_with_uncached_reads)
104
114
 
105
115
  false
106
116
  end
107
117
 
118
+ # Re-serialize when a record moved while its payload was being built, so the payload describes a
119
+ # single moment. Off by default; the extra read costs one indexed column per message, and the
120
+ # re-serialization only happens on the records that were actually contended.
121
+ def publish_consistent_snapshots
122
+ return @publish_consistent_snapshots if defined?(@publish_consistent_snapshots)
123
+
124
+ false
125
+ end
126
+
108
127
  # Off by default so consumers, which fall back to the offset when the field is absent, can be
109
128
  # rolled out first.
110
129
  def include_serialized_at_in_payload
@@ -46,10 +46,10 @@ class Dionysus::Producer::KarafkaResponderGenerator
46
46
 
47
47
  record = records.sample
48
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
52
- payload = serialize_to_payload(records, topic, batch_options)
49
+ # consumers rank duplicates by this stamp, so it has to describe the attempt actually
50
+ # published - with retries that is the last one, not the first
51
+ payload, read_at = serialize_consistently(records, topic, batch_options)
52
+ serialized_at = config.include_serialized_at_in_payload ? read_at : nil
53
53
 
54
54
  event_payload = {
55
55
  event: event,
@@ -75,6 +75,80 @@ class Dionysus::Producer::KarafkaResponderGenerator
75
75
 
76
76
  private
77
77
 
78
+ # A serializer reads a record's own columns and then queries its associations, so anything
79
+ # committed in between lands in the payload beside a timestamp taken before it - the payload
80
+ # describes no single moment. Consumers rank on that timestamp and then compare it against
81
+ # what they have stored, so a payload whose timestamp predates its own contents loses the
82
+ # comparison and is discarded, taking the has_many records embedded in it along with it.
83
+ #
84
+ # Measured on the affected topic in production: serialization spans 95ms at the median and up
85
+ # to 3.5s, and 4.7% of records receiving more than one message had the surviving message
86
+ # report a timestamp older than one already published.
87
+ #
88
+ # So serialize, then check whether the records moved underneath it. If they did, the payload
89
+ # is not a snapshot of anything: reload and serialize again. Retries are bounded, and the last
90
+ # attempt is published rather than dropped - a payload that may be torn still beats no message.
91
+ # returns [payload, read_at] - read_at is when the attempt that produced this payload began
92
+ define_method :serialize_consistently do |records, current_topic, batch_options|
93
+ unless config.publish_consistent_snapshots
94
+ read_at = Time.now.utc
95
+ next [serialize_to_payload(records, current_topic, batch_options), read_at]
96
+ end
97
+
98
+ max_attempts = config.max_snapshot_attempts
99
+ attempts = 0
100
+ loop do
101
+ read_at = Time.now.utc
102
+ before = snapshot_of(records)
103
+ payload = serialize_to_payload(records, current_topic, batch_options)
104
+ attempts += 1
105
+
106
+ if before.nil?
107
+ # nothing to compare against, so the guard did not run on this message at all
108
+ break [instrument_snapshot("unsupported", attempts, records, current_topic, payload), read_at]
109
+ end
110
+ if before == committed_snapshot_of(records)
111
+ break [instrument_snapshot("consistent", attempts, records, current_topic, payload), read_at]
112
+ end
113
+ if attempts >= max_attempts
114
+ # published anyway: a payload that may be torn beats no message. Nothing downstream can
115
+ # tell this apart from a clean one - the payload carries no marker and the consumer sees
116
+ # an ordinary message - so this counter is the only place the outcome is ever visible.
117
+ break [instrument_snapshot("exhausted", attempts, records, current_topic, payload), read_at]
118
+ end
119
+
120
+ records.each { |record| record.reload if record.is_a?(ActiveRecord::Base) && record.persisted? }
121
+ end
122
+ end
123
+
124
+ # One counter rather than several: the denominator, the retry distribution and the failure
125
+ # rate all have to come from the same series or none of them can be read as a rate.
126
+ define_method :instrument_snapshot do |result, attempts, records, current_topic, payload|
127
+ config.instrumenter.increment("dionysus.publish.consistent_snapshot",
128
+ tags: ["result:#{result}", "attempts:#{attempts}", "topic:#{current_topic}",
129
+ "model:#{records.first.class}"])
130
+ payload
131
+ end
132
+
133
+ # nil means there is nothing to compare - a record without timestamps, or not a record at all
134
+ define_method :snapshot_of do |records|
135
+ stamps = records.map { |record| record.updated_at if record.respond_to?(:updated_at) }
136
+ stamps.any?(&:nil?) ? nil : stamps
137
+ end
138
+
139
+ define_method :committed_snapshot_of do |records|
140
+ records.map do |record|
141
+ next record.updated_at unless record.is_a?(ActiveRecord::Base) && record.persisted?
142
+
143
+ # publishing runs inside the request when publish_after_commit is on, where the query
144
+ # cache is live on this connection - a cached read here would report that nothing moved
145
+ # and hand back the torn payload the check exists to catch
146
+ record.class.uncached do
147
+ record.class.where(record.class.primary_key => record.id).pick(:updated_at)
148
+ end
149
+ end
150
+ end
151
+
78
152
  define_method :serialize_to_payload do |records, current_topic, batch_options|
79
153
  if batch_options.to_h[:serialize] == false
80
154
  records.map(&:as_json)
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Dionysus::Producer
4
+ # a contended record can move again while it is being re-serialized; bound the work and publish
5
+ # the last attempt rather than looping or dropping the message. Overridable per producer through
6
+ # config.max_snapshot_attempts - this is only the default.
7
+ MAX_SNAPSHOT_ATTEMPTS = 3
8
+
4
9
  def self.configuration
5
10
  @configuration ||= Dionysus::Producer::Config.new
6
11
  end
@@ -3,5 +3,5 @@
3
3
  module Dionysus
4
4
  module Version
5
5
  end
6
- VERSION = "1.3.0"
6
+ VERSION = "1.4.1"
7
7
  end
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: 1.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Karol Galanciak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord