Class: Dionysus::Producer::KarafkaResponderGenerator
- Inherits:
-
Object
- Object
- Dionysus::Producer::KarafkaResponderGenerator
- Defined in:
- lib/dionysus/producer/karafka_responder_generator.rb
Defined Under Namespace
Classes: NullRegistration
Constant Summary collapse
- TOMBSTONE =
nil
Instance Method Summary collapse
Instance Method Details
#generate(config, topic) ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/dionysus/producer/karafka_responder_generator.rb', line 9 def generate(config, topic) topic_name = topic.to_s genesis_topic_name = topic.genesis_to_s if topic.genesis_replica? responder_klass = Class.new(Dionysus::Producer::BaseResponder) do topic topic_name topic genesis_topic_name if topic.genesis_replica? define_method :respond do |batch, = {}| config.instrumenter.instrument("dionysus.respond.#{self.class.name}") do = {} if (partition_key = .fetch(:partition_key, nil)) [:partition_key] = partition_key end if (key = .fetch(:key, nil)) [:key] = key end if genesis_only?() && genesis_topic_name.nil? raise "cannot execute genesis-only as there is no genesis topic for responder #{self.class.name}" end if batch.nil? unless genesis_only?() respond_to topic_name, TOMBSTONE, ** config.event_bus.publish("dionysus.respond", topic_name: topic_name, message: TOMBSTONE, options: ) end if topic.genesis_replica? respond_to genesis_topic_name, TOMBSTONE, ** config.event_bus.publish("dionysus.respond", topic_name: genesis_topic_name, message: TOMBSTONE, options: ) end else = Array.wrap(batch).map do |event, record_or_records, | records = Array.wrap(record_or_records) return if records.empty? record = records.sample # the offset alone is publish order, and a message published later can carry an # earlier snapshot, so consumers need to know when this payload was actually read serialized_at = config.include_serialized_at_in_payload ? Time.now.utc : nil payload = serialize_consistently(records, topic, ) event_payload = { event: event, model_name: record.model_name.name, data: payload } event_payload[:serialized_at] = serialized_at.iso8601(6) if serialized_at event_payload end unless genesis_only?() respond_to topic_name, { message: }, ** config.event_bus.publish("dionysus.respond", topic_name: topic_name, message: , options: ) end if topic.genesis_replica? respond_to genesis_topic_name, { message: }, ** config.event_bus.publish("dionysus.respond", topic_name: genesis_topic_name, message: , options: ) end end end end private # A serializer reads a record's own columns and then queries its associations, so anything # committed in between lands in the payload beside a timestamp taken before it - the payload # describes no single moment. Consumers rank on that timestamp and then compare it against # what they have stored, so a payload whose timestamp predates its own contents loses the # comparison and is discarded, taking the has_many records embedded in it along with it. # # Measured on the affected topic in production: serialization spans 95ms at the median and up # to 3.5s, and 4.7% of records receiving more than one message had the surviving message # report a timestamp older than one already published. # # So serialize, then check whether the records moved underneath it. If they did, the payload # is not a snapshot of anything: reload and serialize again. Retries are bounded, and the last # attempt is published rather than dropped - a payload that may be torn still beats no message. define_method :serialize_consistently do |records, current_topic, | next serialize_to_payload(records, current_topic, ) unless config.publish_consistent_snapshots max_attempts = config.max_snapshot_attempts attempts = 0 loop do before = snapshot_of(records) payload = serialize_to_payload(records, current_topic, ) attempts += 1 # nothing to compare against, so the guard did not run on this message at all break instrument_snapshot("unsupported", attempts, records, current_topic, payload) if before.nil? if before == committed_snapshot_of(records) break instrument_snapshot("consistent", attempts, records, current_topic, payload) end if attempts >= max_attempts # published anyway: a payload that may be torn beats no message. Nothing downstream can # tell this apart from a clean one - the payload carries no marker and the consumer sees # an ordinary message - so this counter is the only place the outcome is ever visible. break instrument_snapshot("exhausted", attempts, records, current_topic, payload) end records.each { |record| record.reload if record.is_a?(ActiveRecord::Base) && record.persisted? } end end # One counter rather than several: the denominator, the retry distribution and the failure # rate all have to come from the same series or none of them can be read as a rate. define_method :instrument_snapshot do |result, attempts, records, current_topic, payload| config.instrumenter.increment("dionysus.publish.consistent_snapshot", tags: ["result:#{result}", "attempts:#{attempts}", "topic:#{current_topic}", "model:#{records.first.class}"]) payload end # nil means there is nothing to compare - a record without timestamps, or not a record at all define_method :snapshot_of do |records| stamps = records.map { |record| record.updated_at if record.respond_to?(:updated_at) } stamps.any?(&:nil?) ? nil : stamps end define_method :committed_snapshot_of do |records| records.map do |record| next record.updated_at unless record.is_a?(ActiveRecord::Base) && record.persisted? # publishing runs inside the request when publish_after_commit is on, where the query # cache is live on this connection - a cached read here would report that nothing moved # and hand back the torn payload the check exists to catch record.class.uncached do record.class.where(record.class.primary_key => record.id).pick(:updated_at) end end end define_method :serialize_to_payload do |records, current_topic, | if .to_h[:serialize] == false records.map(&:as_json) else record = records.sample model_klass = record.class dependencies = current_topic .models .find(-> { NullRegistration.new }) { |model_registration| model_registration.model_klass == model_klass } . .to_h .fetch(:with, []) current_topic.serializer_klass.serialize(records, dependencies: dependencies) end end define_method :genesis_only? do || .fetch(:genesis_only, false) == true end end responder_klass.instance_exec(topic) do |dionysus_topic| define_singleton_method :publisher_of? do |model_klass| dionysus_topic.publishes_model?(model_klass) end define_singleton_method :publisher_for_topic? do |current_topic| if dionysus_topic.genesis_replica? dionysus_topic.to_s == current_topic.to_s || dionysus_topic.genesis_to_s == current_topic.to_s else dionysus_topic.to_s == current_topic.to_s end end define_singleton_method :publisher_of_model_for_topic? do |model_klass, current_topic| dionysus_topic.publishes_model?(model_klass) && publisher_for_topic?(current_topic) end define_singleton_method :partition_key do dionysus_topic.partition_key end define_singleton_method :primary_topic do responder_klass.topics.values.first end end responder_klass_name = "#{topic.to_s.classify}Responder" Dionysus.send(:remove_const, responder_klass_name) if Dionysus.const_defined?(responder_klass_name) Dionysus.const_set(responder_klass_name, responder_klass) responder_klass end |