dionysus-rb 1.6.0 → 1.7.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: 1b3fb6e16f40d4396b115342f72098bbb2b3f56d8fb630898123e63acce866dc
4
- data.tar.gz: ff8f56aa9b8a142131d92b53d83f7014b987439158c39a7518c096f9e05de980
3
+ metadata.gz: 4b87f084174020b048f571661e800ea514567cce186ed7701c8db43ada5f35be
4
+ data.tar.gz: 62f4126b20a85405bb3337eb9b0bd71d540e39943d2a2e1b5a87b86c1eabbe5a
5
5
  SHA512:
6
- metadata.gz: 9250e85c200c5190a6f85fef44404fa0576d39621960af375b3cbd3cd7ffe172b19fff8021c4f32b1cd63ac453457c6c3bffd504abac9851091d3f4091530b45
7
- data.tar.gz: 28194d92924dbac55258734f66ed3a4090ebe514d2edf8dcfd0368a244c4373111c70be23a32b1d3fb08f271f52ade9434529b4ea6dd91015bee8d0bfaa077b5
6
+ metadata.gz: e0bb5902c1a0a9aaeec93ffbd16b0408fbe0288252ef36e31cca95a67ee2eb15bdb41cf120af4f6a6aec60735a2c2d9f6b571d4aad3a506ef2e214f7731cc7d7
7
+ data.tar.gz: ef7a256c9ca43d3987055df6c49abfe5fcdbf53bd855b7af00ba39f0f4a394dd3923559d69d1b8abc4e8639bc0f7a6c0398b55c2b86c40188a9ee5512ee053e3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.7.1]
4
+ - Parse an ISO8601 string timestamp in the payload instead of ignoring it. 1.7.0 required every timestamp to answer `acts_like_time?`, so a serializer that renders the parent's `updated_at` as a string made the whole class a silent no-op: the parent never looked time-like, no payload was ever examined, no row was ever corrected and `dionysus.publish.timestamp_repair` never fired.
5
+
6
+ ## [1.7.0]
7
+ - 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.
8
+
3
9
  ## [1.6.0]
4
10
  - 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
11
  - 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.1)
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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Dionysus::Producer::EmbeddedTimestampRepair
4
+ TIMESTAMP_ATTRIBUTE = "updated_at"
5
+ PRIMARY_KEY_ATTRIBUTE = "id"
6
+ ISO8601_WITH_OFFSET = %r{\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})\z}
7
+
8
+ # Takes the block that serializes, so a repaired record can be serialized again in one place.
9
+ def self.call(records, config, &)
10
+ new(records, config).call(&)
11
+ end
12
+
13
+ attr_reader :records, :config
14
+ private :records, :config
15
+
16
+ def initialize(records, config)
17
+ @records = records
18
+ @config = config
19
+ end
20
+
21
+ def call
22
+ payload, read_at = yield
23
+ return [payload, read_at] unless config.touch_records_behind_their_embedded_records
24
+ return [payload, read_at] unless payload.is_a?(Array)
25
+ return [payload, read_at] unless any_repaired?(payload)
26
+
27
+ yield
28
+ end
29
+
30
+ private
31
+
32
+ # Pairing is positional, and the serializer is supplied by the application, so a payload that does
33
+ # not line up 1:1 could touch one record with another record's children. Fail closed instead.
34
+ # map, not any? - any? short-circuits and would leave the rest of a batch unrepaired
35
+ def any_repaired?(payload)
36
+ return false unless payload.size == Array(records).size
37
+
38
+ Array(records).zip(payload).map { |record, record_payload| repaired?(record, record_payload) }.any?
39
+ end
40
+
41
+ # A parent whose own timestamp predates a record embedded in it publishes a payload that describes
42
+ # no single moment, and the consumer discards it with its children. Correcting the row keeps the
43
+ # published timestamp equal to the row's own, which is what the republish path relies on.
44
+ def repaired?(record, record_payload)
45
+ return false unless repairable?(record, record_payload)
46
+
47
+ published_at = coerce_time(record_payload[TIMESTAMP_ATTRIBUTE])
48
+ latest = embedded_timestamps(record_payload).max
49
+ return false unless published_at && latest && latest > published_at
50
+
51
+ # WHERE guards the write: a stale read must never move the row backwards. When the row is already
52
+ # ahead nothing is written, and the reload alone repairs the payload.
53
+ updated = record.class.where(record.class.primary_key => record.id)
54
+ .where("#{record.class.quoted_table_name}.#{TIMESTAMP_ATTRIBUTE} < ?", latest)
55
+ .update_all(TIMESTAMP_ATTRIBUTE => latest)
56
+ # the row can be deleted between the guard and here, and raising would abort the whole batch
57
+ record.reload
58
+ instrument(record, updated)
59
+ true
60
+ rescue ActiveRecord::RecordNotFound
61
+ false
62
+ end
63
+
64
+ def repairable?(record, record_payload)
65
+ record.is_a?(ActiveRecord::Base) && record.persisted? &&
66
+ record_payload.is_a?(Hash) && record_payload[PRIMARY_KEY_ATTRIBUTE] == record.id
67
+ end
68
+
69
+ def embedded_timestamps(record_payload)
70
+ record_payload.each_value.flat_map do |value|
71
+ Array.wrap(value).filter_map do |embedded|
72
+ next unless embedded.is_a?(Hash)
73
+
74
+ coerce_time(embedded[TIMESTAMP_ATTRIBUTE])
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
+ # A serializer may render the timestamp it is about to publish, so one payload carries both shapes.
85
+ def coerce_time(value)
86
+ case value
87
+ when ActiveSupport::TimeWithZone, Time then value
88
+ when String then parse_iso8601(value)
89
+ end
90
+ end
91
+
92
+ def parse_iso8601(value)
93
+ return nil unless ISO8601_WITH_OFFSET.match?(value)
94
+
95
+ time = Time.iso8601(value)
96
+ # Time.iso8601 normalizes an impossible date: 2026-09-31 becomes October 1.
97
+ return nil unless value.start_with?(time.strftime("%Y-%m-%d"))
98
+
99
+ time.in_time_zone
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+ 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.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.6.0
4
+ version: 1.7.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-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-08 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