dionysus-rb 1.5.0 → 1.6.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/CHANGELOG.md +8 -0
- data/Gemfile.lock +1 -1
- data/lib/dionysus/consumer/persistor.rb +55 -34
- data/lib/dionysus/consumer/synchronizable_model.rb +12 -0
- 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: 1b3fb6e16f40d4396b115342f72098bbb2b3f56d8fb630898123e63acce866dc
|
|
4
|
+
data.tar.gz: ff8f56aa9b8a142131d92b53d83f7014b987439158c39a7518c096f9e05de980
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9250e85c200c5190a6f85fef44404fa0576d39621960af375b3cbd3cd7ffe172b19fff8021c4f32b1cd63ac453457c6c3bffd504abac9851091d3f4091530b45
|
|
7
|
+
data.tar.gz: 28194d92924dbac55258734f66ed3a4090ebe514d2edf8dcfd0368a244c4373111c70be23a32b1d3fb08f271f52ade9434529b4ea6dd91015bee8d0bfaa077b5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.6.0]
|
|
4
|
+
- 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
|
+
- 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.
|
|
6
|
+
- `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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
3
11
|
## [1.5.0]
|
|
4
12
|
- 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
13
|
- 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
|
@@ -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
|
-
|
|
61
|
+
apply_record(dionysus_event, record, deseralized_record, synced_id, batch_number) if persistable
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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,
|
|
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)
|
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.6.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-
|
|
11
|
+
date: 2026-09-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|