dionysus-rb 1.5.0 → 1.7.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: fa1bffce325e2384847246f0125606bb90dc46d3226d32764e3ad650a74dccc4
4
- data.tar.gz: 07fec41263e2726ddabdcc2cd8b5fae1dcd7452185013e2e9eda2d68b9ec6007
3
+ metadata.gz: a103f61431f824a5d8769b0d2ed6d3ab6b9493690b3503726ed87d8790da7dd9
4
+ data.tar.gz: 9d87121dd840665b4bba91771b5f3e7f2a27e753d2dd374b5e461f1db172aa8d
5
5
  SHA512:
6
- metadata.gz: 76e8759abcf8533a5f3ef0559b97f3fb7fdcc379552204ef33456820108acd0271e88cc9c66f757f539731dbd0d95e574dd4afaa9e5e537e18468a191ae4c26c
7
- data.tar.gz: d7f572eb5e5440bf599a38d33fb31c78d194d226a414f17890f536edf9150e58193242a761f47a46e4073d532c7001e676fe48487812a45cee5d66d711ec910b
6
+ metadata.gz: 784b7e7894cfabc80c1146a9ac93ce7d466a872cb3d86caec664fd1f2c1fa453d125ca2528b03e1a6d55b71fc15f87fdd54eb05cebb32639365c144a7303777d
7
+ data.tar.gz: b22a989af8d7a2cfe867f6ad1485ebf88f312ba3eb7e0ea0df52663cd60a552cf1576c1c7fc85ee4caffb576397c2684d06f596e8bf993c6d6b3cdf610dcf837
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.7.0]
4
+ - Correct a row whose own `updated_at` predates a record embedded in its payload, then serialize again, behind `config.touch_records_behind_their_embedded_records` (default `false`). A serializer reads the parent's columns before the associations, so a child committed in between arrives under a timestamp older than itself, and the consumer discards the payload together with that child.
5
+
6
+ ## [1.6.0]
7
+ - Judge a payload's embedded children on their own timestamps instead of dropping them with the parent. When `persist_with_dionysus?` rejects a record, `persist` used to `next` past the whole loop body - the child `persist` calls included - so every `has_many` and `has_one` record embedded in that payload was discarded with it, with no error and no counter. The parent is still not written; only the traversal of its children is decoupled from its verdict. Each child re-enters `persist` and is guarded on its own `synced_updated_at || synced_created_at`, so a child the consumer has never seen is created and a child older than the row held locally is still skipped.
8
+ - Reproduced at the consumer, not argued: `spec/embedded_children_persistence_spec.rb` runs a whole batch through the generated consumer with deduplication in place, from two failures observed in production. In the first, all seven money-bearing messages carried an `updated_at` 44 ms older than the zero-money payload already accepted, and the payment embedded in them - a row that did not exist locally - was never created. Three control examples pin the fixture down: the same shape at a timestamp the guard accepts persists the money, creates the embedded payment and calls `resolve_to_many_association`, so a red assertion is a statement about this gem rather than about the payload being malformed.
9
+ - `resolve_to_many_association` and `resolve_to_one_association` are suppressed for the whole subtree beneath a rejected parent. Cancelling or deleting the children absent from the payload's list is how a genuine deletion propagates, and it requires a complete current list - which is exactly what a payload already judged stale cannot provide. Consuming applications implement that hook destructively, several with `destroy_all`, and the old `next` was shielding them by accident; suppressing it deliberately keeps that behaviour rather than changing it.
10
+ - Children beneath a rejected parent are compared strictly, through the new `SynchronizableModel#advances_dionysus_state?`. The ordinary guard accepts a tie, and a purge stamps the cancel column with `update_all` without bumping `synced_updated_at` - so a stale payload that merely ties on a child's timestamp is precisely how a cancelled child would be revived. A tie carries nothing that is not already held, so requiring the child to be demonstrably newer closes that without costing a repair. `persist_with_dionysus?` itself is untouched and behaves as before on the ordinary path. A child the payload carries no timestamp for at all is refused outright for the same reason: a payload already judged stale proves nothing about it, and where `resolve_to_many_association` hard-deletes, applying one would bring back a child a fresher payload had removed.
11
+ - The stale verdict is sticky for the whole subtree, which is a trade rather than a free win: a child that is itself fresh now advances while its own children keep the generation they had, because the purge needs a complete list that only a non-stale ancestor can vouch for. Before this change the child did not advance either, so the subtree stayed internally consistent and equally wrong; a later payload that passes the root guard repairs it. Recorded as a pending example.
12
+ - Not fixed here, and left as a pending example rather than a silent gap: when deduplication keeps a message whose `updated_at` the guard rejects while a sibling in the same group would have been accepted, the parent's own columns are still lost. Recovering that needs the guard - not a payload timestamp - to choose the survivor. Keeping siblings whose `updated_at` looks newer is disproven by the existing torn-payload fixture in the deduplication specs, where the correct payload carries the *lower* `updated_at`; ranking on any single key has a production counterexample, so `RemoveDuplicatesStrategy` is unchanged. A consuming application that needs the parent half today can pass `params_batch_transformation: nil` for the topic, which is covered by an example here.
13
+
3
14
  ## [1.5.0]
4
15
  - Publish the survivor of a collapsed run of duplicates once more after a delay, behind `config.republish_deduplicated_records` (default `false`) with `config.republish_deduplicated_records_delay` (default 30 seconds). It does nothing unless `remove_consecutive_duplicates_before_publishing` is also on, since there is no run to collapse otherwise.
5
16
  - What a collapsed run means. `DuplicatesFilter` keeps the last record of each consecutive run for the same resource, event and topic. A run longer than one only happens when the record was written again while an earlier message for it was still queued - which is the same window in which a payload can be serialized across a write and end up carrying an `updated_at` older than its own contents. `publish_consistent_snapshots` bounds its retries and publishes the last attempt regardless, so a payload that may still be torn does get published; this schedules one more publish once the writes have settled.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dionysus-rb (1.5.0)
4
+ dionysus-rb (1.7.0)
5
5
  activerecord (>= 5)
6
6
  activesupport (>= 3.2)
7
7
  concurrent-ruby
@@ -9,14 +9,14 @@ class Dionysus::Consumer::Persistor
9
9
  @topic = topic
10
10
  end
11
11
 
12
- def persist(dionysus_event, batch_number)
12
+ def persist(dionysus_event, batch_number, under_stale_parent: false)
13
13
  if dionysus_event.generic_event?
14
14
  if dionysus_event.created? && topic.options[:import] == true
15
15
  persist_via_dionysus_create(dionysus_event, batch_number)
16
16
  elsif dionysus_event.destroyed? && topic.options[:import] == true
17
17
  persist_via_dionysus_destroy(dionysus_event, batch_number)
18
18
  else
19
- persist_standard_event(dionysus_event, batch_number)
19
+ persist_standard_event(dionysus_event, batch_number, under_stale_parent: under_stale_parent)
20
20
  end
21
21
  else
22
22
  log_unknown_event_type(dionysus_event)
@@ -43,12 +43,9 @@ class Dionysus::Consumer::Persistor
43
43
  config.model_factory.for_model(dionysus_event.model_name)
44
44
  end
45
45
 
46
- def persist_standard_event(dionysus_event, batch_number)
46
+ def persist_standard_event(dionysus_event, batch_number, under_stale_parent: false)
47
47
  Array.wrap(dionysus_event.transformed_data).each do |deseralized_record|
48
48
  model_klass = find_model_klass(dionysus_event) or return
49
- attributes = deseralized_record.attributes
50
- has_one_relationships = deseralized_record.has_one
51
- has_many_relationships = deseralized_record.has_many
52
49
  synced_id = deseralized_record.synced_id
53
50
 
54
51
  if synced_id.nil?
@@ -59,38 +56,53 @@ class Dionysus::Consumer::Persistor
59
56
  record = Dionysus::Consumer::SynchronizableModel.new(config,
60
57
  model_klass.find_or_initialize_by(config.synced_id_attribute => synced_id))
61
58
  event_updated_at = deseralized_record.synced_updated_at || deseralized_record.synced_created_at
59
+ persistable = persistable?(record, event_updated_at, under_stale_parent)
62
60
 
63
- next unless record.persist_with_dionysus?(event_updated_at)
61
+ apply_record(dionysus_event, record, deseralized_record, synced_id, batch_number) if persistable
64
62
 
65
- record.assign_attributes_from_dionysus(attributes)
66
- if dionysus_event.destroyed?
67
- instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.remove_with_dionysus") do
68
- record.remove_with_dionysus(deseralized_record) if dionysus_event.aggregate_root?
69
- end
70
- else
71
- instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.restore_with_dionysus") do
72
- record.restore_with_dionysus if record.restorable?(deseralized_record)
73
- end
74
- end
63
+ # children re-enter persist and are guarded on their own timestamp; only the child list is untrusted
64
+ persist_relationships(dionysus_event, record, deseralized_record, batch_number,
65
+ under_stale_parent: under_stale_parent || !persistable)
66
+ end
67
+ end
75
68
 
76
- dionysus_event.local_changes[[dionysus_event.model_name, synced_id]] = record.changes if record.changes.present?
69
+ def persistable?(record, event_updated_at, under_stale_parent)
70
+ if under_stale_parent
71
+ record.advances_dionysus_state?(event_updated_at)
72
+ else
73
+ record.persist_with_dionysus?(event_updated_at)
74
+ end
75
+ end
77
76
 
78
- instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.save") do
79
- record.save unless record.destroyed?
77
+ def apply_record(dionysus_event, record, deseralized_record, synced_id, batch_number)
78
+ record.assign_attributes_from_dionysus(deseralized_record.attributes)
79
+ if dionysus_event.destroyed?
80
+ instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.remove_with_dionysus") do
81
+ record.remove_with_dionysus(deseralized_record) if dionysus_event.aggregate_root?
82
+ end
83
+ else
84
+ instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.restore_with_dionysus") do
85
+ record.restore_with_dionysus if record.restorable?(deseralized_record)
80
86
  end
87
+ end
88
+
89
+ dionysus_event.local_changes[[dionysus_event.model_name, synced_id]] = record.changes if record.changes.present?
90
+
91
+ instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.save") do
92
+ record.save unless record.destroyed?
93
+ end
94
+ end
81
95
 
82
- instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_many_relationships") do
83
- has_many_relationships.each do |relationship_name, relationship_records|
84
- persist_to_many_relationship(dionysus_event, relationship_name, record, relationship_records,
85
- batch_number)
86
- end
96
+ def persist_relationships(dionysus_event, record, deseralized_record, batch_number, under_stale_parent:)
97
+ instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_many_relationships") do
98
+ deseralized_record.has_many.each do |relationship|
99
+ persist_to_many_relationship(dionysus_event, relationship, record, batch_number, under_stale_parent: under_stale_parent)
87
100
  end
101
+ end
88
102
 
89
- instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_one_relationships") do
90
- has_one_relationships.each do |relationship_name, relationship_record|
91
- persist_to_one_relationship(dionysus_event, relationship_name, record, relationship_record,
92
- batch_number)
93
- end
103
+ instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_one_relationships") do
104
+ deseralized_record.has_one.each do |relationship|
105
+ persist_to_one_relationship(dionysus_event, relationship, record, batch_number, under_stale_parent: under_stale_parent)
94
106
  end
95
107
  end
96
108
  end
@@ -99,7 +111,9 @@ class Dionysus::Consumer::Persistor
99
111
  Dionysus.logger.debug("[Dionysus] unknown event type #{dionysus_event.event_name}")
100
112
  end
101
113
 
102
- def persist_to_one_relationship(original_event, relationship_name, parent_model_record, record, batch_number)
114
+ def persist_to_one_relationship(original_event, relationship, parent_model_record, batch_number,
115
+ under_stale_parent: false)
116
+ relationship_name, record = relationship
103
117
  instrumentation_arguments = {
104
118
  event_name: original_event.event_name,
105
119
  parent_model_record: parent_model_record.model_name.to_s,
@@ -115,17 +129,21 @@ class Dionysus::Consumer::Persistor
115
129
  relationship_name, records, aggregate_root: false)
116
130
 
117
131
  instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_one_relationship.#{relationship_name}.persist") do
118
- persist(dionysus_event, batch_number)
132
+ persist(dionysus_event, batch_number, under_stale_parent: under_stale_parent)
119
133
  original_event.local_changes.merge!(dionysus_event.local_changes)
120
134
  end
121
135
 
136
+ return if under_stale_parent
137
+
122
138
  instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_one_relationship.#{relationship_name}.resolve_to_one_association") do
123
139
  parent_model_record.resolve_to_one_association(relationship_name, record.synced_id)
124
140
  end
125
141
  end
126
142
  end
127
143
 
128
- def persist_to_many_relationship(original_event, relationship_name, parent_model_record, records, batch_number)
144
+ def persist_to_many_relationship(original_event, relationship, parent_model_record, batch_number,
145
+ under_stale_parent: false)
146
+ relationship_name, records = relationship
129
147
  instrumentation_arguments = {
130
148
  event_name: original_event.event_name,
131
149
  parent_model_record: parent_model_record.model_name.to_s,
@@ -140,10 +158,13 @@ class Dionysus::Consumer::Persistor
140
158
  relationship_name, records, aggregate_root: false)
141
159
 
142
160
  instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_many_relationship.#{relationship_name}.persist") do
143
- persist(dionysus_event, batch_number)
161
+ persist(dionysus_event, batch_number, under_stale_parent: under_stale_parent)
144
162
  original_event.local_changes.merge!(dionysus_event.local_changes)
145
163
  end
146
164
 
165
+ # the purge needs a complete current list, which a payload already judged stale cannot provide
166
+ return if under_stale_parent
167
+
147
168
  synced_ids_of_related_records = records.map(&:synced_id)
148
169
  instrument("dionysus.consume.#{topic}.batch_number_#{batch_number}.persist.persist_to_many_relationship.#{relationship_name}.resolve_to_many_association") do
149
170
  parent_model_record.resolve_to_many_association(relationship_name, synced_ids_of_related_records)
@@ -25,6 +25,18 @@ class Dionysus::Consumer::SynchronizableModel < SimpleDelegator
25
25
  (synced_at && event_updated_at && event_updated_at >= synced_at) || synced_at.nil? || event_updated_at.nil?
26
26
  end
27
27
 
28
+ # A purge stamps the cancel column without bumping synced_updated_at, so under a stale payload a tie
29
+ # on the child's timestamp is how a cancelled child gets revived. Demand demonstrably newer.
30
+ # An unstamped child is refused outright: a payload the guard has already rejected proves nothing
31
+ # about a child it carries no timestamp for, and applying one would resurrect a child that a fresher
32
+ # payload had already deleted. Refusing it is exactly what the old blanket `next` did.
33
+ def advances_dionysus_state?(event_updated_at)
34
+ return false if event_updated_at.nil?
35
+
36
+ persist_with_dionysus?(event_updated_at) &&
37
+ !(synced_at && event_updated_at == synced_at)
38
+ end
39
+
28
40
  def assign_attributes_from_dionysus(attributes)
29
41
  public_send("#{synced_data_attribute}=", attributes)
30
42
  reverse_mapping = config.attributes_mapping_for_model(model.model_name).to_a.to_h(&:reverse)
@@ -10,7 +10,8 @@ class Dionysus::Producer::Config
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
12
  :include_serialized_at_in_payload, :publish_with_uncached_reads, :publish_consistent_snapshots,
13
- :max_snapshot_attempts, :republish_deduplicated_records, :republish_deduplicated_records_delay
13
+ :max_snapshot_attempts, :republish_deduplicated_records, :republish_deduplicated_records_delay,
14
+ :touch_records_behind_their_embedded_records
14
15
 
15
16
  def self.default_sidekiq_queue
16
17
  :dionysus
@@ -98,6 +99,14 @@ class Dionysus::Producer::Config
98
99
  @observers_inline_maximum_size || 1000
99
100
  end
100
101
 
102
+ # Corrects a row whose own timestamp predates a record embedded in its payload, then serializes
103
+ # again. Off by default: it writes to the source table from the publish path.
104
+ def touch_records_behind_their_embedded_records
105
+ return @touch_records_behind_their_embedded_records if defined?(@touch_records_behind_their_embedded_records)
106
+
107
+ false
108
+ end
109
+
101
110
  # Off by default so it can be switched on per producer, and switched back off in one env change
102
111
  # if the extra reads ever cost more than they are worth.
103
112
  # How many times a payload may be re-serialized before the last attempt is published as it stands.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::EmbeddedTimestampRepair
4
+ TIMESTAMP_ATTRIBUTE = "updated_at"
5
+ PRIMARY_KEY_ATTRIBUTE = "id"
6
+
7
+ # Takes the block that serializes, so a repaired record can be serialized again in one place.
8
+ def self.call(records, config, &)
9
+ new(records, config).call(&)
10
+ end
11
+
12
+ attr_reader :records, :config
13
+ private :records, :config
14
+
15
+ def initialize(records, config)
16
+ @records = records
17
+ @config = config
18
+ end
19
+
20
+ def call
21
+ payload, read_at = yield
22
+ return [payload, read_at] unless config.touch_records_behind_their_embedded_records
23
+ return [payload, read_at] unless payload.is_a?(Array)
24
+ return [payload, read_at] unless any_repaired?(payload)
25
+
26
+ yield
27
+ end
28
+
29
+ private
30
+
31
+ # Pairing is positional, and the serializer is supplied by the application, so a payload that does
32
+ # not line up 1:1 could touch one record with another record's children. Fail closed instead.
33
+ # map, not any? - any? short-circuits and would leave the rest of a batch unrepaired
34
+ def any_repaired?(payload)
35
+ return false unless payload.size == Array(records).size
36
+
37
+ Array(records).zip(payload).map { |record, record_payload| repaired?(record, record_payload) }.any?
38
+ end
39
+
40
+ # A parent whose own timestamp predates a record embedded in it publishes a payload that describes
41
+ # no single moment, and the consumer discards it with its children. Correcting the row keeps the
42
+ # published timestamp equal to the row's own, which is what the republish path relies on.
43
+ def repaired?(record, record_payload)
44
+ return false unless repairable?(record, record_payload)
45
+
46
+ latest = embedded_timestamps(record_payload).max
47
+ return false unless latest && latest > record_payload[TIMESTAMP_ATTRIBUTE]
48
+
49
+ # WHERE guards the write: a stale read must never move the row backwards. When the row is already
50
+ # ahead nothing is written, and the reload alone repairs the payload.
51
+ updated = record.class.where(record.class.primary_key => record.id)
52
+ .where("#{record.class.quoted_table_name}.#{TIMESTAMP_ATTRIBUTE} < ?", latest)
53
+ .update_all(TIMESTAMP_ATTRIBUTE => latest)
54
+ # the row can be deleted between the guard and here, and raising would abort the whole batch
55
+ record.reload
56
+ instrument(record, updated)
57
+ true
58
+ rescue ActiveRecord::RecordNotFound
59
+ false
60
+ end
61
+
62
+ def repairable?(record, record_payload)
63
+ record.is_a?(ActiveRecord::Base) && record.persisted? &&
64
+ record_payload.is_a?(Hash) && record_payload[PRIMARY_KEY_ATTRIBUTE] == record.id &&
65
+ time_like?(record_payload[TIMESTAMP_ATTRIBUTE])
66
+ end
67
+
68
+ def embedded_timestamps(record_payload)
69
+ record_payload.each_value.flat_map do |value|
70
+ Array.wrap(value).filter_map do |embedded|
71
+ next unless embedded.is_a?(Hash)
72
+
73
+ timestamp = embedded[TIMESTAMP_ATTRIBUTE]
74
+ timestamp if time_like?(timestamp)
75
+ end
76
+ end
77
+ end
78
+
79
+ def instrument(record, updated)
80
+ config.instrumenter.increment("dionysus.publish.timestamp_repair",
81
+ tags: ["model:#{record.class}", "outcome:#{updated.zero? ? "stale_read" : "row_behind"}"])
82
+ end
83
+
84
+ def time_like?(value)
85
+ value.respond_to?(:acts_like_time?) && value.acts_like_time?
86
+ end
87
+ end
@@ -48,7 +48,8 @@ class Dionysus::Producer::KarafkaResponderGenerator
48
48
 
49
49
  # consumers rank duplicates by this stamp, so it has to describe the attempt actually
50
50
  # published - with retries that is the last one, not the first
51
- payload, read_at = serialize_consistently(records, topic, batch_options)
51
+ payload, read_at = Dionysus::Producer::EmbeddedTimestampRepair
52
+ .call(records, config) { serialize_consistently(records, topic, batch_options) }
52
53
  serialized_at = config.include_serialized_at_in_payload ? read_at : nil
53
54
 
54
55
  event_payload = {
@@ -3,5 +3,5 @@
3
3
  module Dionysus
4
4
  module Version
5
5
  end
6
- VERSION = "1.5.0"
6
+ VERSION = "1.7.0"
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.5.0
4
+ version: 1.7.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-31 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -500,6 +500,7 @@ files:
500
500
  - lib/dionysus/producer/base_responder.rb
501
501
  - lib/dionysus/producer/config.rb
502
502
  - lib/dionysus/producer/deleted_record_serializer.rb
503
+ - lib/dionysus/producer/embedded_timestamp_repair.rb
503
504
  - lib/dionysus/producer/genesis.rb
504
505
  - lib/dionysus/producer/genesis/performed.rb
505
506
  - lib/dionysus/producer/genesis/stream_job.rb