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 +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile.lock +1 -1
- data/lib/dionysus/producer/config.rb +10 -1
- data/lib/dionysus/producer/embedded_timestamp_repair.rb +103 -0
- data/lib/dionysus/producer/karafka_responder_generator.rb +2 -1
- data/lib/dionysus/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b87f084174020b048f571661e800ea514567cce186ed7701c8db43ada5f35be
|
|
4
|
+
data.tar.gz: 62f4126b20a85405bb3337eb9b0bd71d540e39943d2a2e1b5a87b86c1eabbe5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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 =
|
|
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 = {
|
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.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-
|
|
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
|