dionysus-rb 1.6.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: 1b3fb6e16f40d4396b115342f72098bbb2b3f56d8fb630898123e63acce866dc
4
- data.tar.gz: ff8f56aa9b8a142131d92b53d83f7014b987439158c39a7518c096f9e05de980
3
+ metadata.gz: a103f61431f824a5d8769b0d2ed6d3ab6b9493690b3503726ed87d8790da7dd9
4
+ data.tar.gz: 9d87121dd840665b4bba91771b5f3e7f2a27e753d2dd374b5e461f1db172aa8d
5
5
  SHA512:
6
- metadata.gz: 9250e85c200c5190a6f85fef44404fa0576d39621960af375b3cbd3cd7ffe172b19fff8021c4f32b1cd63ac453457c6c3bffd504abac9851091d3f4091530b45
7
- data.tar.gz: 28194d92924dbac55258734f66ed3a4090ebe514d2edf8dcfd0368a244c4373111c70be23a32b1d3fb08f271f52ade9434529b4ea6dd91015bee8d0bfaa077b5
6
+ metadata.gz: 784b7e7894cfabc80c1146a9ac93ce7d466a872cb3d86caec664fd1f2c1fa453d125ca2528b03e1a6d55b71fc15f87fdd54eb05cebb32639365c144a7303777d
7
+ data.tar.gz: b22a989af8d7a2cfe867f6ad1485ebf88f312ba3eb7e0ea0df52663cd60a552cf1576c1c7fc85ee4caffb576397c2684d06f596e8bf993c6d6b3cdf610dcf837
data/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
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
+
3
6
  ## [1.6.0]
4
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.
5
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.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dionysus-rb (1.6.0)
4
+ dionysus-rb (1.7.0)
5
5
  activerecord (>= 5)
6
6
  activesupport (>= 3.2)
7
7
  concurrent-ruby
@@ -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.6.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.6.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-09-01 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