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 +4 -4
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +1 -1
- data/README.md +14 -0
- data/lib/dionysus/producer/config.rb +20 -1
- data/lib/dionysus/producer/karafka_responder_generator.rb +78 -4
- data/lib/dionysus/producer.rb +5 -0
- data/lib/dionysus/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aea1780da49737ea39a412112502ccefec1921bcaa8e4d9e3f4a17c01e359cb1
|
|
4
|
+
data.tar.gz: 0de793797246fa1aa63209eb9713529df3bf3b51aedca2d03be485ab3b140f43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
data/lib/dionysus/producer.rb
CHANGED
|
@@ -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
|
data/lib/dionysus/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-08-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|