dionysus-rb 1.1.0 → 1.3.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 +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +1 -1
- data/lib/dionysus/consumer/params_batch_transformations/remove_duplicates_strategy.rb +27 -3
- data/lib/dionysus/producer/config.rb +18 -1
- data/lib/dionysus/producer/karafka_responder_generator.rb +7 -1
- data/lib/dionysus/producer/outbox/publisher.rb +54 -38
- 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: 968407d443681c04bfc7426b8925d7f63db69a09763357413d01218c03ec3ad8
|
|
4
|
+
data.tar.gz: baf5e739a77bc2d48ab5197591ddb59c6d970bdaed68479014d5afbec5698f0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 121b9c6af2b90700a7af1aba098282cfe05df901c633dfcc71d7845bda935b5e124b21c8bedab20895e9c5dd11a3dd12c2edd049c1bcd9b984c7df0da8a246b5
|
|
7
|
+
data.tar.gz: 6594b49acf6f64bb59475bc4a8a3a70c5747a09dcb72d3bc815e544bf3016bf0df732f3b15f0b3b7c8106578ba3c8ed49f4206d5b206e240285d376b6e08b31f
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.3.0]
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Read the record and its associations without the query cache while publishing, behind `config.publish_with_uncached_reads` (default `false`). The payload is built from the record plus the associations declared with `with:`. When `publish_after_commit` is on, publishing runs inside the request, where Rails' query cache is live on that connection - so a row read earlier in the request is served from the cache while its associations are queried fresh. The payload then carries an `updated_at` older than the data it contains.
|
|
8
|
+
- Measured in production on the sibling gem over a two and a half hour window on one topic: of 1,476 records that received more than one message, 141 had a payload whose `updated_at` moved backwards as `serialized_at` moved forwards, and in 69 of those the message that wins deduplication carried an `updated_at` lower than one already published at a lower offset. One record published a payload serialized at `09:27:23.662` reporting `updated_at 09:27:22.139557`, when a payload serialized 1.3 seconds earlier had already reported `09:27:22.345092` - a value a row's `updated_at` cannot return to.
|
|
9
|
+
- The consequence is not a stale field but a discarded record. Consumers rank duplicates and then apply `persist_with_dionysus?`, which compares the payload's `updated_at` against what is stored; a payload that loses that comparison 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 new child rows are never created, with no error, no counter and nothing to self-heal it.
|
|
10
|
+
- This is why no ordering over the fields already in the message could fix both known incidents: `updated_at` was in the message all along and is simply wrong. `serialized_at`, added in 1.2.0, correctly identifies the last-serialized message - which is precisely the one the guard rejects when its `updated_at` is stale.
|
|
11
|
+
- Rolling this out needs no consumer changes: the fix is entirely on the producer, and the payload's shape is unchanged.
|
|
12
|
+
|
|
13
|
+
## [1.2.0]
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
3
22
|
## [1.1.0]
|
|
4
23
|
|
|
5
24
|
### Fixed
|
data/Gemfile.lock
CHANGED
|
@@ -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
|
|
|
@@ -12,17 +16,37 @@ class Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy
|
|
|
12
16
|
# the idea is following:
|
|
13
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. from each group keep the message
|
|
16
|
-
# publish
|
|
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
22
|
def transform_messages_array(params_batch)
|
|
19
23
|
params_batch
|
|
20
24
|
.to_a
|
|
21
25
|
.group_by { |batch| grouping_key_by_event_and_id(batch) }
|
|
22
|
-
.map { |_, group| group
|
|
26
|
+
.map { |_, group| freshest(group) }
|
|
23
27
|
.flatten
|
|
24
28
|
end
|
|
25
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
|
+
|
|
26
50
|
def grouping_key_by_event_and_id(batch)
|
|
27
51
|
[
|
|
28
52
|
batch.payload.fetch("message").first.fetch("event"),
|
|
@@ -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, :publish_with_uncached_reads
|
|
12
13
|
|
|
13
14
|
def self.default_sidekiq_queue
|
|
14
15
|
:dionysus
|
|
@@ -96,6 +97,22 @@ class Dionysus::Producer::Config
|
|
|
96
97
|
@observers_inline_maximum_size || 1000
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
# Off by default so it can be switched on per producer, and switched back off in one env change
|
|
101
|
+
# if the extra reads ever cost more than they are worth.
|
|
102
|
+
def publish_with_uncached_reads
|
|
103
|
+
return @publish_with_uncached_reads if defined?(@publish_with_uncached_reads)
|
|
104
|
+
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Off by default so consumers, which fall back to the offset when the field is absent, can be
|
|
109
|
+
# rolled out first.
|
|
110
|
+
def include_serialized_at_in_payload
|
|
111
|
+
return @include_serialized_at_in_payload if defined?(@include_serialized_at_in_payload)
|
|
112
|
+
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
99
116
|
def remove_consecutive_duplicates_before_publishing
|
|
100
117
|
return @remove_consecutive_duplicates_before_publishing if defined?(@remove_consecutive_duplicates_before_publishing)
|
|
101
118
|
|
|
@@ -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
|
|
@@ -11,52 +11,56 @@ class Dionysus::Producer::Outbox::Publisher
|
|
|
11
11
|
def publish(outbox_record, options = {})
|
|
12
12
|
return if Dionysus::Producer::Suppressor.suppressed?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
resource_class.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
with_consistent_reads do
|
|
15
|
+
instrument("publishing_with_dionysus") do
|
|
16
|
+
resource_class = outbox_record.resource_class.constantize
|
|
17
|
+
primary_key = resource_class.primary_key
|
|
18
|
+
primary_key_value = outbox_record.resource_id
|
|
19
|
+
topic = outbox_record.topic
|
|
20
|
+
resource = resource_class.find_by(primary_key => primary_key_value) ||
|
|
21
|
+
resource_class.new(primary_key => primary_key_value)
|
|
22
|
+
event_name = outbox_record.event_name
|
|
23
|
+
if resource.new_record? && outbox_record.created_event?
|
|
24
|
+
logger.error(
|
|
25
|
+
"Attempted to publish #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
|
|
26
|
+
)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if resource.new_record? && outbox_record.updated_event?
|
|
31
|
+
logger.error(
|
|
32
|
+
"There was an update of #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
|
|
33
|
+
)
|
|
34
|
+
return
|
|
35
|
+
end
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
published_count = publish_for_top_level_resource(outbox_record, resource, event_name, topic, options) +
|
|
38
|
+
publish_for_dependency(resource, topic, options)
|
|
39
|
+
handle_nothing_published(resource, event_name, topic) if published_count.zero?
|
|
40
|
+
published_count
|
|
41
|
+
end
|
|
40
42
|
end
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def publish_observers(outbox_record)
|
|
44
46
|
return if Dionysus::Producer::Suppressor.suppressed?
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
resource_class.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
with_consistent_reads do
|
|
49
|
+
instrument("publishing_observers_with_dionysus") do
|
|
50
|
+
resource_class = outbox_record.resource_class.constantize
|
|
51
|
+
primary_key = resource_class.primary_key
|
|
52
|
+
primary_key_value = outbox_record.resource_id
|
|
53
|
+
resource = resource_class.find_by(primary_key => primary_key_value) ||
|
|
54
|
+
resource_class.new(primary_key => primary_key_value)
|
|
55
|
+
changeset = outbox_record.transformed_changeset
|
|
56
|
+
|
|
57
|
+
Dionysus::Producer.observers_with_responders_for(resource,
|
|
58
|
+
changeset).each do |observers, responder|
|
|
59
|
+
if observers.count > config.observers_inline_maximum_size
|
|
60
|
+
execute_genesis_for_observers(observers, responder)
|
|
61
|
+
else
|
|
62
|
+
observers.each { |observer_record| publish_observer(observer_record, responder) }
|
|
63
|
+
end
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
end
|
|
@@ -64,6 +68,18 @@ class Dionysus::Producer::Outbox::Publisher
|
|
|
64
68
|
|
|
65
69
|
private
|
|
66
70
|
|
|
71
|
+
# The payload is built from the record and its associations. Publishing runs inside the request
|
|
72
|
+
# when publish_after_commit is on, where Rails' query cache is live on that connection, so a row
|
|
73
|
+
# read earlier in the request can be served from the cache while its associations are queried
|
|
74
|
+
# fresh - producing a payload whose updated_at is older than the data it carries. Consumers rank
|
|
75
|
+
# and guard on updated_at, so such a payload is silently discarded, taking its embedded
|
|
76
|
+
# associations with it.
|
|
77
|
+
def with_consistent_reads(&)
|
|
78
|
+
return yield unless config.publish_with_uncached_reads
|
|
79
|
+
|
|
80
|
+
ActiveRecord::Base.uncached(&)
|
|
81
|
+
end
|
|
82
|
+
|
|
67
83
|
delegate :instrumenter, :error_handler, to: :config
|
|
68
84
|
delegate :instrument, to: :instrumenter
|
|
69
85
|
delegate :logger, to: Dionysus
|
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.3.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: 2026-08-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|