dionysus-rb 1.2.0 → 1.4.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 +23 -0
- data/Gemfile.lock +1 -1
- data/README.md +14 -0
- data/lib/dionysus/producer/config.rb +28 -1
- data/lib/dionysus/producer/karafka_responder_generator.rb +68 -1
- data/lib/dionysus/producer/outbox/publisher.rb +54 -38
- data/lib/dionysus/producer.rb +5 -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: 8cd74dab3609a68e4919adb2913c5e2d428b2bd044ec785988eae123d125677b
|
|
4
|
+
data.tar.gz: 15ae10c89ce12d63f0d452bf8ebfb835a8b3593784048c003a28a093a9c77250
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3a2a0e0179804ff474dc9bb7575ba565e3c4377f6755cfb31aed5c783b7a3915966af0158a50132155a18c588e44495540d47a7463e475d07260f9db4cea6ef
|
|
7
|
+
data.tar.gz: 2e4450ae8743746067d81ab3c28614c4293fec570fdb550a9240b44f4e8ed097d7f3ecc110e03f51135c8fcd8237835e0a5a3ca90f754009b9146bd2e83e4866
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.4.0]
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Re-serialize a record whose row moved while its payload was being built, behind `config.publish_consistent_snapshots` (default `false`), so the payload describes a single moment.
|
|
8
|
+
- The defect, reproduced against a real broker in `spec/torn_payload_reproduction_spec.rb`. A serializer reads a record's own columns and then queries its associations. Anything committed in between lands in the payload beside a timestamp taken before it, so the payload's `updated_at` can predate the records embedded next to it. The reproduction publishes from a record read fresh moments earlier and still emits a payload tens of milliseconds adrift carrying an association row the timestamp predates - no stale object, no query cache, no clock skew and no replica involved.
|
|
9
|
+
- Why that is a lost record rather than a stale field. Consumers rank duplicates and then compare the payload's `updated_at` against what they have stored. A payload whose timestamp predates its own contents loses that comparison and is skipped by `next`, and the has_many records embedded in it are persisted further down the same loop body, so they are dropped with it: the parent keeps its old values and the child rows are never created, with no error and no counter.
|
|
10
|
+
- Measured on the sibling gem's affected topic in production: serialization spans 95 ms at the median and up to 3.5 s, which is ample time for a write to arrive; 141 of 1,476 records receiving more than one message showed `updated_at` moving backwards, and in 69 of those the message that wins deduplication reported a timestamp older than one already published at a lower offset.
|
|
11
|
+
- The check reads the row's timestamp back with the query cache bypassed. Publishing runs inside the request when `publish_after_commit` is on, where the cache is live on that connection, and a cached read would report that nothing had moved and hand back the very payload the check exists to catch.
|
|
12
|
+
- Retries are bounded by `config.max_snapshot_attempts` (default `Producer::MAX_SNAPSHOT_ATTEMPTS`, 3) and the last attempt is published rather than dropped - a payload that may still be torn beats no message at all. Records without an `updated_at`, and anything that is not an `ActiveRecord::Base`, are serialized once as before.
|
|
13
|
+
- The retry is the expensive half, and its cost runs opposite to its benefit. Measured over 5 events on one record with the serializer held at the production median of 95 ms: a quiet record costs nothing beyond one indexed read per message, but a record written every 40 ms pins to the ceiling on every event - 15 serializations instead of 5, and 1.5 s instead of 0.5 s - and still publishes an attempt that may be torn. The hottest records are both the most expensive to guard and the least likely to converge, so `max_snapshot_attempts` is configurable to bound it.
|
|
14
|
+
- Every guarded message increments `dionysus.publish.consistent_snapshot` exactly once, tagged `result:consistent|exhausted|unsupported`, `attempts:N`, `topic` and `model`. `exhausted` is the case worth alerting on: the payload is published with no marker and consumers see an ordinary message, so the counter is the only place that outcome is ever visible. Nothing is emitted while the feature is off, so an empty series reads as "not running" rather than "running clean".
|
|
15
|
+
|
|
16
|
+
## [1.3.0]
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Read the record and its associations without the query cache while publishing, behind `config.publish_with_uncached_reads` (default `false`). The payload is built from the record plus the associations declared with `with:`. When `publish_after_commit` is on, publishing runs inside the request, where Rails' query cache is live on that connection - so a row read earlier in the request is served from the cache while its associations are queried fresh. The payload then carries an `updated_at` older than the data it contains.
|
|
21
|
+
- Measured in production on the sibling gem over a two and a half hour window on one topic: of 1,476 records that received more than one message, 141 had a payload whose `updated_at` moved backwards as `serialized_at` moved forwards, and in 69 of those the message that wins deduplication carried an `updated_at` lower than one already published at a lower offset. One record published a payload serialized at `09:27:23.662` reporting `updated_at 09:27:22.139557`, when a payload serialized 1.3 seconds earlier had already reported `09:27:22.345092` - a value a row's `updated_at` cannot return to.
|
|
22
|
+
- The consequence is not a stale field but a discarded record. Consumers rank duplicates and then apply `persist_with_dionysus?`, which compares the payload's `updated_at` against what is stored; a payload that loses that comparison is skipped by `next`, and the `has_many` records embedded in it are persisted further down the same loop body, so they are dropped with it. The parent keeps its old values and the new child rows are never created, with no error, no counter and nothing to self-heal it.
|
|
23
|
+
- This is why no ordering over the fields already in the message could fix both known incidents: `updated_at` was in the message all along and is simply wrong. `serialized_at`, added in 1.2.0, correctly identifies the last-serialized message - which is precisely the one the guard rejects when its `updated_at` is stale.
|
|
24
|
+
- Rolling this out needs no consumer changes: the fix is entirely on the producer, and the payload's shape is unchanged.
|
|
25
|
+
|
|
3
26
|
## [1.2.0]
|
|
4
27
|
|
|
5
28
|
### Fixed
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -337,10 +337,24 @@ Dionysus::Producer.configure do |config|
|
|
|
337
337
|
config.outbox_worker_publishing_delay = 5 # non required, defaults to 0 a delay in seconds until the outbox record is considered publishable. Check `Publishing records right after the transaction is committed` section for more details.
|
|
338
338
|
config.remove_consecutive_duplicates_before_publishing = true # not required, defaults to false. If set to true, the consecutive duplicates in the publishable batch will be removed and only one message will be published to a given topic. For example, if for whatever reason there are ten messages in a row for a given topic to publish `user_updated` ecent, only the last will be published. Check `Dionysus::Consumer::ParamsBatchTransformations::RemoveDuplicatesStrategy` for exact implementation. To verify if this feature is useful, it's recommended to browse Karafka UI and check messages in the topics if there are any obvious duplicates happening often.
|
|
339
339
|
config.observers_inline_maximum_size = 100 # not required, defaults to 1000. This config setting matters in case there is a huge amount of dependent records (observers). If the threshold is exceeded, the observers will be published via Genesis process to not cause issues like blocking the outbox worker.
|
|
340
|
+
config.publish_consistent_snapshots = true # not required, defaults to `false`. A serializer reads a record's own columns and then queries its associations, so a write landing in between produces a payload whose `updated_at` predates the records embedded next to it - consumers rank and guard on that timestamp, so such a payload is discarded along with its embedded associations. When enabled, the row's timestamp is read back (bypassing the query cache) after serializing and the payload is re-serialized if the record moved. Emits the `dionysus.publish.consistent_snapshot` counter described below.
|
|
341
|
+
config.max_snapshot_attempts = 2 # not required, defaults to 3. Only relevant with `publish_consistent_snapshots` enabled. A record written faster than it serializes fails the check on every attempt, so it pays the full serialization cost this many times over and still publishes a payload that may be torn - lower this to bound that cost. Set it to 1 to keep the check and its metric while disabling re-serialization entirely.
|
|
340
342
|
config.sidekiq_queue = :default # required, defaults to :dionysus. The queue will be used for a genesis process
|
|
341
343
|
end
|
|
342
344
|
```
|
|
343
345
|
|
|
346
|
+
##### Monitoring consistent snapshots
|
|
347
|
+
|
|
348
|
+
With `publish_consistent_snapshots` enabled, every guarded message increments `dionysus.publish.consistent_snapshot` exactly once, tagged with `result`, `attempts`, `topic` and `model`. Nothing is emitted when the feature is off, so an empty series means the guard is not running rather than running clean.
|
|
349
|
+
|
|
350
|
+
| `result` | meaning |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `consistent` | the record had not moved by the time the payload was built. With `attempts:1` the first try was already clean; higher values mean it converged after re-serializing. |
|
|
353
|
+
| `exhausted` | the record was still moving at `max_snapshot_attempts`. The payload is published anyway - a payload that may be torn beats no message - but it carries no marker and consumers see an ordinary message, so this counter is the only place the outcome is visible. |
|
|
354
|
+
| `unsupported` | the records carry no `updated_at`, so the guard could not run on that message at all. |
|
|
355
|
+
|
|
356
|
+
Watch the `exhausted` rate before and after a rollout: the retry cost scales with write rate multiplied by serialization span, so the hottest records are both the most expensive to guard and the least likely to converge.
|
|
357
|
+
|
|
344
358
|
##### DionysusOutbox model
|
|
345
359
|
|
|
346
360
|
Generate a model for the outbox:
|
|
@@ -9,7 +9,8 @@ class Dionysus::Producer::Config
|
|
|
9
9
|
:transactional_outbox_enabled, :sidekiq_queue, :publisher_service_name,
|
|
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
|
-
:include_serialized_at_in_payload
|
|
12
|
+
:include_serialized_at_in_payload, :publish_with_uncached_reads, :publish_consistent_snapshots,
|
|
13
|
+
:max_snapshot_attempts
|
|
13
14
|
|
|
14
15
|
def self.default_sidekiq_queue
|
|
15
16
|
:dionysus
|
|
@@ -97,6 +98,32 @@ class Dionysus::Producer::Config
|
|
|
97
98
|
@observers_inline_maximum_size || 1000
|
|
98
99
|
end
|
|
99
100
|
|
|
101
|
+
# Off by default so it can be switched on per producer, and switched back off in one env change
|
|
102
|
+
# if the extra reads ever cost more than they are worth.
|
|
103
|
+
# How many times a payload may be re-serialized before the last attempt is published as it stands.
|
|
104
|
+
# This is the expensive half of publish_consistent_snapshots: a record written faster than it
|
|
105
|
+
# serializes fails the check on every attempt, so it pays the full serialization cost this many
|
|
106
|
+
# times over and still publishes a payload that may be torn. Lower it to bound that cost; 1
|
|
107
|
+
# disables re-serialization while leaving the check (and its metric) in place.
|
|
108
|
+
def max_snapshot_attempts
|
|
109
|
+
@max_snapshot_attempts || Dionysus::Producer::MAX_SNAPSHOT_ATTEMPTS
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def publish_with_uncached_reads
|
|
113
|
+
return @publish_with_uncached_reads if defined?(@publish_with_uncached_reads)
|
|
114
|
+
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Re-serialize when a record moved while its payload was being built, so the payload describes a
|
|
119
|
+
# single moment. Off by default; the extra read costs one indexed column per message, and the
|
|
120
|
+
# re-serialization only happens on the records that were actually contended.
|
|
121
|
+
def publish_consistent_snapshots
|
|
122
|
+
return @publish_consistent_snapshots if defined?(@publish_consistent_snapshots)
|
|
123
|
+
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
100
127
|
# Off by default so consumers, which fall back to the offset when the field is absent, can be
|
|
101
128
|
# rolled out first.
|
|
102
129
|
def include_serialized_at_in_payload
|
|
@@ -49,7 +49,7 @@ class Dionysus::Producer::KarafkaResponderGenerator
|
|
|
49
49
|
# the offset alone is publish order, and a message published later can carry an
|
|
50
50
|
# earlier snapshot, so consumers need to know when this payload was actually read
|
|
51
51
|
serialized_at = config.include_serialized_at_in_payload ? Time.now.utc : nil
|
|
52
|
-
payload =
|
|
52
|
+
payload = serialize_consistently(records, topic, batch_options)
|
|
53
53
|
|
|
54
54
|
event_payload = {
|
|
55
55
|
event: event,
|
|
@@ -75,6 +75,73 @@ class Dionysus::Producer::KarafkaResponderGenerator
|
|
|
75
75
|
|
|
76
76
|
private
|
|
77
77
|
|
|
78
|
+
# A serializer reads a record's own columns and then queries its associations, so anything
|
|
79
|
+
# committed in between lands in the payload beside a timestamp taken before it - the payload
|
|
80
|
+
# describes no single moment. Consumers rank on that timestamp and then compare it against
|
|
81
|
+
# what they have stored, so a payload whose timestamp predates its own contents loses the
|
|
82
|
+
# comparison and is discarded, taking the has_many records embedded in it along with it.
|
|
83
|
+
#
|
|
84
|
+
# Measured on the affected topic in production: serialization spans 95ms at the median and up
|
|
85
|
+
# to 3.5s, and 4.7% of records receiving more than one message had the surviving message
|
|
86
|
+
# report a timestamp older than one already published.
|
|
87
|
+
#
|
|
88
|
+
# So serialize, then check whether the records moved underneath it. If they did, the payload
|
|
89
|
+
# is not a snapshot of anything: reload and serialize again. Retries are bounded, and the last
|
|
90
|
+
# attempt is published rather than dropped - a payload that may be torn still beats no message.
|
|
91
|
+
define_method :serialize_consistently do |records, current_topic, batch_options|
|
|
92
|
+
next serialize_to_payload(records, current_topic, batch_options) unless config.publish_consistent_snapshots
|
|
93
|
+
|
|
94
|
+
max_attempts = config.max_snapshot_attempts
|
|
95
|
+
attempts = 0
|
|
96
|
+
loop do
|
|
97
|
+
before = snapshot_of(records)
|
|
98
|
+
payload = serialize_to_payload(records, current_topic, batch_options)
|
|
99
|
+
attempts += 1
|
|
100
|
+
|
|
101
|
+
# nothing to compare against, so the guard did not run on this message at all
|
|
102
|
+
break instrument_snapshot("unsupported", attempts, records, current_topic, payload) if before.nil?
|
|
103
|
+
if before == committed_snapshot_of(records)
|
|
104
|
+
break instrument_snapshot("consistent", attempts, records, current_topic, payload)
|
|
105
|
+
end
|
|
106
|
+
if attempts >= max_attempts
|
|
107
|
+
# published anyway: a payload that may be torn beats no message. Nothing downstream can
|
|
108
|
+
# tell this apart from a clean one - the payload carries no marker and the consumer sees
|
|
109
|
+
# an ordinary message - so this counter is the only place the outcome is ever visible.
|
|
110
|
+
break instrument_snapshot("exhausted", attempts, records, current_topic, payload)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
records.each { |record| record.reload if record.is_a?(ActiveRecord::Base) && record.persisted? }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One counter rather than several: the denominator, the retry distribution and the failure
|
|
118
|
+
# rate all have to come from the same series or none of them can be read as a rate.
|
|
119
|
+
define_method :instrument_snapshot do |result, attempts, records, current_topic, payload|
|
|
120
|
+
config.instrumenter.increment("dionysus.publish.consistent_snapshot",
|
|
121
|
+
tags: ["result:#{result}", "attempts:#{attempts}", "topic:#{current_topic}",
|
|
122
|
+
"model:#{records.first.class}"])
|
|
123
|
+
payload
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# nil means there is nothing to compare - a record without timestamps, or not a record at all
|
|
127
|
+
define_method :snapshot_of do |records|
|
|
128
|
+
stamps = records.map { |record| record.updated_at if record.respond_to?(:updated_at) }
|
|
129
|
+
stamps.any?(&:nil?) ? nil : stamps
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
define_method :committed_snapshot_of do |records|
|
|
133
|
+
records.map do |record|
|
|
134
|
+
next record.updated_at unless record.is_a?(ActiveRecord::Base) && record.persisted?
|
|
135
|
+
|
|
136
|
+
# publishing runs inside the request when publish_after_commit is on, where the query
|
|
137
|
+
# cache is live on this connection - a cached read here would report that nothing moved
|
|
138
|
+
# and hand back the torn payload the check exists to catch
|
|
139
|
+
record.class.uncached do
|
|
140
|
+
record.class.where(record.class.primary_key => record.id).pick(:updated_at)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
78
145
|
define_method :serialize_to_payload do |records, current_topic, batch_options|
|
|
79
146
|
if batch_options.to_h[:serialize] == false
|
|
80
147
|
records.map(&:as_json)
|
|
@@ -11,52 +11,56 @@ class Dionysus::Producer::Outbox::Publisher
|
|
|
11
11
|
def publish(outbox_record, options = {})
|
|
12
12
|
return if Dionysus::Producer::Suppressor.suppressed?
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
resource_class.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
with_consistent_reads do
|
|
15
|
+
instrument("publishing_with_dionysus") do
|
|
16
|
+
resource_class = outbox_record.resource_class.constantize
|
|
17
|
+
primary_key = resource_class.primary_key
|
|
18
|
+
primary_key_value = outbox_record.resource_id
|
|
19
|
+
topic = outbox_record.topic
|
|
20
|
+
resource = resource_class.find_by(primary_key => primary_key_value) ||
|
|
21
|
+
resource_class.new(primary_key => primary_key_value)
|
|
22
|
+
event_name = outbox_record.event_name
|
|
23
|
+
if resource.new_record? && outbox_record.created_event?
|
|
24
|
+
logger.error(
|
|
25
|
+
"Attempted to publish #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
|
|
26
|
+
)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if resource.new_record? && outbox_record.updated_event?
|
|
31
|
+
logger.error(
|
|
32
|
+
"There was an update of #{resource.class}, id: #{resource.id} but it was deleted, that should never happen!"
|
|
33
|
+
)
|
|
34
|
+
return
|
|
35
|
+
end
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
published_count = publish_for_top_level_resource(outbox_record, resource, event_name, topic, options) +
|
|
38
|
+
publish_for_dependency(resource, topic, options)
|
|
39
|
+
handle_nothing_published(resource, event_name, topic) if published_count.zero?
|
|
40
|
+
published_count
|
|
41
|
+
end
|
|
40
42
|
end
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def publish_observers(outbox_record)
|
|
44
46
|
return if Dionysus::Producer::Suppressor.suppressed?
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
resource_class.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
with_consistent_reads do
|
|
49
|
+
instrument("publishing_observers_with_dionysus") do
|
|
50
|
+
resource_class = outbox_record.resource_class.constantize
|
|
51
|
+
primary_key = resource_class.primary_key
|
|
52
|
+
primary_key_value = outbox_record.resource_id
|
|
53
|
+
resource = resource_class.find_by(primary_key => primary_key_value) ||
|
|
54
|
+
resource_class.new(primary_key => primary_key_value)
|
|
55
|
+
changeset = outbox_record.transformed_changeset
|
|
56
|
+
|
|
57
|
+
Dionysus::Producer.observers_with_responders_for(resource,
|
|
58
|
+
changeset).each do |observers, responder|
|
|
59
|
+
if observers.count > config.observers_inline_maximum_size
|
|
60
|
+
execute_genesis_for_observers(observers, responder)
|
|
61
|
+
else
|
|
62
|
+
observers.each { |observer_record| publish_observer(observer_record, responder) }
|
|
63
|
+
end
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
end
|
|
@@ -64,6 +68,18 @@ class Dionysus::Producer::Outbox::Publisher
|
|
|
64
68
|
|
|
65
69
|
private
|
|
66
70
|
|
|
71
|
+
# The payload is built from the record and its associations. Publishing runs inside the request
|
|
72
|
+
# when publish_after_commit is on, where Rails' query cache is live on that connection, so a row
|
|
73
|
+
# read earlier in the request can be served from the cache while its associations are queried
|
|
74
|
+
# fresh - producing a payload whose updated_at is older than the data it carries. Consumers rank
|
|
75
|
+
# and guard on updated_at, so such a payload is silently discarded, taking its embedded
|
|
76
|
+
# associations with it.
|
|
77
|
+
def with_consistent_reads(&)
|
|
78
|
+
return yield unless config.publish_with_uncached_reads
|
|
79
|
+
|
|
80
|
+
ActiveRecord::Base.uncached(&)
|
|
81
|
+
end
|
|
82
|
+
|
|
67
83
|
delegate :instrumenter, :error_handler, to: :config
|
|
68
84
|
delegate :instrument, to: :instrumenter
|
|
69
85
|
delegate :logger, to: Dionysus
|
data/lib/dionysus/producer.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Dionysus::Producer
|
|
4
|
+
# a contended record can move again while it is being re-serialized; bound the work and publish
|
|
5
|
+
# the last attempt rather than looping or dropping the message. Overridable per producer through
|
|
6
|
+
# config.max_snapshot_attempts - this is only the default.
|
|
7
|
+
MAX_SNAPSHOT_ATTEMPTS = 3
|
|
8
|
+
|
|
4
9
|
def self.configuration
|
|
5
10
|
@configuration ||= Dionysus::Producer::Config.new
|
|
6
11
|
end
|
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.4.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-
|
|
11
|
+
date: 2026-08-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|