undertow 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 53f9ee409c2585ff551f21884f10e8eec276ee750e6f3c12109280cc79fdd934
4
+ data.tar.gz: e343c5be340f08b271e5851a91c0a3c5f363ce28357b5deb83657ae5c242f494
5
+ SHA512:
6
+ metadata.gz: 28d46d6ddfb4e02ef12dcc001295e9f39034708af79ba12761553d61c32fe96d6d64769cdf45c9df8d3276ff19c9de0080b843ff54cc575e94f7a947db545c99
7
+ data.tar.gz: 49c01b579d3797b755d52c077890753abba9defe3b49ca7e7871564c4ee31627328933a00e0fa0f3e031af16f5e2cb54354516dc277bc87dfc18e68cd464379c
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ # Low-level Redis SET operations used by Trackable callbacks and DrainJob.
5
+ # All methods are no-ops when tracking is disabled or Redis is unavailable.
6
+ module Buffer
7
+ class << self
8
+ def push_pending(model_name, ids)
9
+ return if Undertow.tracking_disabled?
10
+
11
+ with_redis do |r|
12
+ r.sadd(Registry.pending_key(model_name), ids)
13
+ r.sadd(Registry::MODELS_KEY, model_name)
14
+ end
15
+ end
16
+
17
+ def push_deleted(model_name, ids)
18
+ return if Undertow.tracking_disabled?
19
+
20
+ with_redis do |r|
21
+ r.sadd(Registry.deleted_key(model_name), ids)
22
+ r.sadd(Registry::MODELS_KEY, model_name)
23
+ end
24
+ end
25
+
26
+ def pop_pending(model_name, count)
27
+ with_redis { |r| r.spop(Registry.pending_key(model_name), count) } || []
28
+ end
29
+
30
+ def pop_deleted(model_name, count)
31
+ with_redis { |r| r.spop(Registry.deleted_key(model_name), count) } || []
32
+ end
33
+
34
+ def pending_model_names
35
+ with_redis { |r| r.smembers(Registry::MODELS_KEY) } || []
36
+ end
37
+
38
+ def deregister_model(model_name)
39
+ with_redis { |r| r.srem(Registry::MODELS_KEY, model_name) }
40
+ end
41
+
42
+ def reregister_model(model_name)
43
+ with_redis { |r| r.sadd(Registry::MODELS_KEY, model_name) }
44
+ end
45
+
46
+ def remaining(model_name)
47
+ with_redis do |r|
48
+ r.scard(Registry.pending_key(model_name)) +
49
+ r.scard(Registry.deleted_key(model_name))
50
+ end || 0
51
+ end
52
+
53
+ def restore_pending(model_name, ids)
54
+ with_redis { |r| r.sadd(Registry.pending_key(model_name), ids) } if ids.any?
55
+ end
56
+
57
+ def restore_deleted(model_name, ids)
58
+ with_redis { |r| r.sadd(Registry.deleted_key(model_name), ids) } if ids.any?
59
+ end
60
+
61
+ def pending?
62
+ with_redis { |r| r.scard(Registry::MODELS_KEY) > 0 } || false
63
+ end
64
+
65
+ # Acquire the distributed drain lock using SET NX. Returns true if the lock
66
+ # was acquired, false if it was already held. TTL is a safety valve in case
67
+ # the job process dies before releasing it.
68
+ def acquire_drain_lock(ttl: 30)
69
+ lock_key = Undertow.configuration.drain_lock_key
70
+ return true unless lock_key
71
+
72
+ with_redis { |r| r.set(lock_key, '1', nx: true, ex: ttl) } || false
73
+ end
74
+
75
+ # Release the drain lock. Called at the start of DrainJob#perform so the
76
+ # scheduler can enqueue another job for IDs that arrive while this one runs.
77
+ def release_drain_lock
78
+ lock_key = Undertow.configuration.drain_lock_key
79
+ return unless lock_key
80
+
81
+ with_redis { |r| r.del(lock_key) }
82
+ end
83
+
84
+ private
85
+
86
+ def with_redis
87
+ client = Undertow.configuration.redis!
88
+ if client.respond_to?(:with)
89
+ client.with { |conn| yield conn }
90
+ else
91
+ yield client
92
+ end
93
+ rescue Redis::BaseConnectionError, Redis::CommandError => e
94
+ Rails.logger.error("Undertow: Redis error: #{e.message}") if defined?(Rails)
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ class Configuration
5
+ # A Redis client or connection pool. Must respond to :sadd, :srem, :smembers,
6
+ # :spop, :scard, :del. Injected by the host application:
7
+ #
8
+ # Undertow.configure { |c| c.redis = Redis.new(url: ENV['REDIS_URL']) }
9
+ #
10
+ attr_accessor :redis
11
+
12
+ # Maximum number of IDs to pop from the buffer per drain per model.
13
+ attr_accessor :max_batch
14
+
15
+ # ActiveJob queue to use for DrainJob.
16
+ attr_accessor :queue_name
17
+
18
+ # Redis key used for the distributed drain lock. The scheduler acquires this
19
+ # lock (SET NX) before enqueueing DrainJob; the job releases it immediately
20
+ # on start so new work arriving mid-drain gets its own job on the next tick.
21
+ # Set to nil to disable lock management entirely.
22
+ attr_accessor :drain_lock_key
23
+
24
+ def initialize
25
+ @max_batch = 1_000
26
+ @queue_name = :undertow
27
+ @drain_lock_key = 'undertow:drain:lock'
28
+ end
29
+
30
+ def redis!
31
+ redis or raise 'Undertow.configuration.redis is not set'
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ # Drains the per-model Redis buffers and delivers batches of dirty IDs to
5
+ # each model's configured on_drain handler.
6
+ #
7
+ # Publishes two ActiveSupport::Notifications events:
8
+ # drain.undertow — after a successful on_drain call ({ model:, ids:, deleted_ids: })
9
+ # error.undertow — when on_drain raises ({ model:, exception: })
10
+ class DrainJob < ActiveJob::Base
11
+ queue_as { Undertow.configuration.queue_name }
12
+
13
+ def perform
14
+ # Release the lock before draining so the scheduler can enqueue another job
15
+ # for IDs that arrive while this one is running.
16
+ Buffer.release_drain_lock
17
+
18
+ model_names = Buffer.pending_model_names
19
+ return if model_names.empty?
20
+
21
+ model_names.each { |name| drain_model(name) }
22
+ end
23
+
24
+ private
25
+
26
+ def drain_model(model_name)
27
+ max = Undertow.configuration.max_batch
28
+
29
+ # Deregister before popping — any concurrent push will re-add the model,
30
+ # preventing the race where srem fires after a concurrent sadd.
31
+ Buffer.deregister_model(model_name)
32
+
33
+ ids = Buffer.pop_pending(model_name, max)
34
+ deleted_ids = Buffer.pop_deleted(model_name, max)
35
+ return if ids.empty? && deleted_ids.empty?
36
+
37
+ # If the batch was capped, re-register so the next scheduler tick picks up.
38
+ Buffer.reregister_model(model_name) if Buffer.remaining(model_name).positive?
39
+
40
+ config = Registry[model_name]
41
+ raise "No Undertow config registered for #{model_name}" unless config
42
+ raise "#{model_name} is missing undertow_on_drain" unless config.on_drain
43
+
44
+ config.on_drain.call(model_name, ids, deleted_ids)
45
+
46
+ ActiveSupport::Notifications.instrument('drain.undertow', {
47
+ model: model_name,
48
+ ids: ids,
49
+ deleted_ids: deleted_ids
50
+ })
51
+ rescue StandardError => e
52
+ Buffer.restore_pending(model_name, ids) if ids&.any?
53
+ Buffer.restore_deleted(model_name, deleted_ids) if deleted_ids&.any?
54
+ Buffer.reregister_model(model_name)
55
+ ActiveSupport::Notifications.instrument('error.undertow', { model: model_name, exception: e })
56
+ Rails.logger.error("[Undertow::DrainJob] #{model_name}: #{e.message}") if defined?(Rails)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ # Class-level DSL extended onto ActiveRecord::Base by the Railtie. Any model
5
+ # that calls these methods automatically registers itself with Undertow and
6
+ # gets Trackable behavior wired in at boot — no include needed.
7
+ #
8
+ # class Activity < ApplicationRecord
9
+ # undertow_on_drain ->(model_name, ids, deleted_ids) { ActivityReindexJob.perform_later(ids, deleted_ids) }
10
+ # undertow_skip %w[lock_version searchkick_reindexing]
11
+ #
12
+ # undertow_depends_on :provider, foreign_key: :provider_id, watched_columns: %w[approved mobile]
13
+ # undertow_depends_on :location_series,
14
+ # resolver: ->(ls) { Activity.where(series_id: ls.series_id) },
15
+ # watched_columns: %w[location_id hidden]
16
+ # end
17
+ #
18
+ module DSL
19
+ def undertow_on_drain(callable)
20
+ _undertow_config.on_drain = callable
21
+ _undertow_ensure_trackable!
22
+ end
23
+
24
+ def undertow_skip(columns)
25
+ _undertow_config.skip_columns = columns
26
+ _undertow_ensure_trackable!
27
+ end
28
+
29
+ def undertow_depends_on(association, foreign_key: nil, resolver: nil, watched_columns: nil)
30
+ raise ArgumentError, 'provide exactly one of foreign_key: or resolver:' unless foreign_key.nil? ^ resolver.nil?
31
+
32
+ _undertow_config.dependencies << {
33
+ association: association,
34
+ foreign_key: foreign_key,
35
+ resolver: resolver,
36
+ watched_columns: watched_columns
37
+ }.freeze
38
+ _undertow_ensure_trackable!
39
+ end
40
+
41
+ private
42
+
43
+ def _undertow_config
44
+ Registry.register(name)
45
+ end
46
+
47
+ def _undertow_ensure_trackable!
48
+ include Undertow::Trackable unless ancestors.include?(Undertow::Trackable)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ class Railtie < Rails::Railtie
5
+ # Extend ActiveRecord::Base with the Undertow DSL so any model can call
6
+ # undertow_on_drain, undertow_skip, and undertow_depends_on in its class body.
7
+ initializer 'undertow.extend_active_record' do
8
+ ActiveSupport.on_load(:active_record) { extend Undertow::DSL }
9
+ end
10
+
11
+ # Wire after_commit callbacks onto every registered model and its dependencies
12
+ # after all models are loaded. Runs on each code reload in development.
13
+ config.to_prepare do
14
+ Registry.all.each do |model_name, config|
15
+ model_name.constantize.register_undertow_callbacks!(config)
16
+ rescue NameError => e
17
+ Rails.logger.warn("[Undertow] Could not load #{model_name}: #{e.message}")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ # Holds the declared dependency configuration for every tracked model.
5
+ # Populated at class load time via the DSL (undertow_on_drain, undertow_skip,
6
+ # undertow_depends_on); consumed by the Railtie and DrainJob.
7
+ module Registry
8
+ MODELS_KEY = 'undertow:pending:models'
9
+
10
+ class ModelConfig
11
+ attr_reader :model_name, :dependencies
12
+ attr_accessor :on_drain, :skip_columns
13
+
14
+ def initialize(model_name)
15
+ @model_name = model_name
16
+ @dependencies = []
17
+ @skip_columns = []
18
+ @on_drain = nil
19
+ end
20
+ end
21
+
22
+ class << self
23
+ # Returns an existing config or creates a new one for model_name.
24
+ # Called by each DSL macro the first time it fires on a model.
25
+ def register(model_name)
26
+ all[model_name] ||= ModelConfig.new(model_name)
27
+ end
28
+
29
+ def all
30
+ @all ||= {}
31
+ end
32
+
33
+ def [](model_name)
34
+ all[model_name]
35
+ end
36
+
37
+ def key?(model_name)
38
+ all.key?(model_name)
39
+ end
40
+
41
+ def pending_key(model_name) = "undertow:pending:#{model_name}"
42
+ def deleted_key(model_name) = "undertow:deleted:#{model_name}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ # ActiveRecord concern mixed in automatically when a model uses the Undertow DSL
5
+ # (undertow_on_drain, undertow_skip, undertow_depends_on). Never included manually.
6
+ #
7
+ # Provides class-level callback registration and the skip_columns guard.
8
+ # Callbacks are wired at boot by the Railtie after all models are loaded.
9
+ module Trackable
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ # Columns listed here suppress self-tracking when they are the *only*
14
+ # things that changed — prevents feedback loops from columns updated by
15
+ # the drain handler itself.
16
+ class_attribute :_undertow_ignored_columns, default: [], instance_writer: false
17
+ end
18
+
19
+ class_methods do
20
+ # Called by the Railtie after all models/associations are loaded.
21
+ # Idempotent — safe to call multiple times (e.g. in reloading environments).
22
+ def register_undertow_callbacks!(config)
23
+ return if @_undertow_callbacks_registered
24
+
25
+ @_undertow_callbacks_registered = true
26
+
27
+ self._undertow_ignored_columns = config.skip_columns
28
+
29
+ _register_self_callbacks!
30
+ (config.dependencies || []).each { |dep| _register_dep_callbacks!(dep) }
31
+ end
32
+
33
+ private
34
+
35
+ def _register_self_callbacks!
36
+ after_commit :_push_self_pending, on: %i[create update]
37
+ after_destroy :_push_self_deleted
38
+ after_restore :_push_self_pending if respond_to?(:after_restore)
39
+ end
40
+
41
+ def _register_dep_callbacks!(dep)
42
+ dep_class = _resolve_dep_class(dep)
43
+ return unless dep_class
44
+
45
+ root_class = self
46
+ watched = dep[:watched_columns].presence # [] treated same as nil — watch all
47
+
48
+ resolver = dep[:resolver] || begin
49
+ fk = dep[:foreign_key]
50
+ ->(record) { root_class.where(fk => record.id) }
51
+ end
52
+
53
+ push_pending = ->(record) {
54
+ ids = resolver.call(record).pluck(:id)
55
+ next unless ids.any?
56
+
57
+ root_class._push_undertow_pending(ids)
58
+ }
59
+
60
+ # Skip create/update callback when watched_columns is set and none changed.
61
+ # Note: saved_changes is empty when touched via belongs_to touch: true (bypasses
62
+ # dirty tracking) — that correctly falls through to skip here.
63
+ dep_class.after_commit on: %i[create update] do
64
+ next if watched && (saved_changes.keys & watched).none?
65
+
66
+ push_pending.call(self)
67
+ end
68
+
69
+ # Dep destroyed — reindex surviving root records. SoftDeletable calls
70
+ # run_callbacks(:destroy), which fires after_destroy, but update_columns does NOT
71
+ # trigger after_commit, so scoping after_commit to [:create, :update] above
72
+ # ensures destroy commits don't double-fire.
73
+ dep_class.after_destroy { push_pending.call(self) }
74
+
75
+ # Dep restored — after_restore is the only hook that fires because restore!
76
+ # uses update_columns, bypassing after_commit.
77
+ if dep_class.respond_to?(:after_restore)
78
+ dep_class.after_restore { push_pending.call(self) }
79
+ end
80
+ end
81
+
82
+ def _resolve_dep_class(dep)
83
+ if dep[:resolver]
84
+ dep[:association].to_s.classify.constantize
85
+ else
86
+ reflect_on_association(dep[:association])&.klass ||
87
+ dep[:association].to_s.classify.constantize
88
+ end
89
+ end
90
+
91
+ public
92
+
93
+ def _push_undertow_pending(ids)
94
+ Buffer.push_pending(name, ids)
95
+ end
96
+
97
+ def _push_undertow_deleted(ids)
98
+ Buffer.push_deleted(name, ids)
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def _push_self_pending
105
+ ignored = self.class._undertow_ignored_columns
106
+ return if ignored.any? && saved_changes.any? && (saved_changes.keys - ignored).empty?
107
+
108
+ self.class._push_undertow_pending([id])
109
+ end
110
+
111
+ def _push_self_deleted
112
+ self.class._push_undertow_deleted([id])
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ VERSION = '0.1.0'
5
+ end
data/lib/undertow.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_record'
5
+
6
+ require_relative 'undertow/version'
7
+ require_relative 'undertow/configuration'
8
+ require_relative 'undertow/registry'
9
+ require_relative 'undertow/buffer'
10
+ require_relative 'undertow/dsl'
11
+ require_relative 'undertow/trackable'
12
+ require_relative 'undertow/drain_job'
13
+ require_relative 'undertow/railtie' if defined?(Rails::Railtie)
14
+
15
+ module Undertow
16
+ class << self
17
+ def configure
18
+ yield configuration
19
+ end
20
+
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ # Suppress all buffer pushes inside the block. Useful in tests and
26
+ # data migrations where dependency callbacks should not fire.
27
+ def without_tracking
28
+ previous = Thread.current[:undertow_disabled]
29
+ Thread.current[:undertow_disabled] = true
30
+ yield
31
+ ensure
32
+ Thread.current[:undertow_disabled] = previous
33
+ end
34
+
35
+ def tracking_disabled?
36
+ Thread.current[:undertow_disabled]
37
+ end
38
+
39
+ # Called from the host application's scheduler on each tick. Checks for
40
+ # pending work, acquires the drain lock, and enqueues DrainJob — no other
41
+ # wiring required:
42
+ #
43
+ # every(1.second, 'undertow') { Undertow.tick }
44
+ #
45
+ def tick
46
+ return unless Buffer.pending?
47
+ return unless Buffer.acquire_drain_lock
48
+
49
+ DrainJob.perform_later
50
+ end
51
+ end
52
+ end
data/undertow.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/undertow/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'undertow'
7
+ spec.version = Undertow::VERSION
8
+ spec.authors = ['Nathan Allen']
9
+ spec.email = ['hello@nallenscott.com']
10
+ spec.summary = 'Buffered, dependency-aware change propagation for ActiveRecord models'
11
+ spec.homepage = 'https://github.com/nallenscott/undertow'
12
+ spec.license = 'MIT'
13
+
14
+ spec.metadata = {
15
+ 'source_code_uri' => 'https://github.com/nallenscott/undertow',
16
+ 'changelog_uri' => 'https://github.com/nallenscott/undertow/blob/main/CHANGELOG.md',
17
+ 'bug_tracker_uri' => 'https://github.com/nallenscott/undertow/issues'
18
+ }
19
+
20
+ spec.required_ruby_version = '>= 3.0'
21
+
22
+ spec.files = Dir['lib/**/*.rb'] + ['undertow.gemspec']
23
+
24
+ spec.add_dependency 'activerecord', '~> 7.0'
25
+ spec.add_dependency 'activesupport', '~> 7.0'
26
+ spec.add_dependency 'activejob', '~> 7.0'
27
+ spec.add_dependency 'redis', '~> 5.0'
28
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: undertow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Allen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activejob
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redis
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ description:
70
+ email:
71
+ - hello@nallenscott.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/undertow.rb
77
+ - lib/undertow/buffer.rb
78
+ - lib/undertow/configuration.rb
79
+ - lib/undertow/drain_job.rb
80
+ - lib/undertow/dsl.rb
81
+ - lib/undertow/railtie.rb
82
+ - lib/undertow/registry.rb
83
+ - lib/undertow/trackable.rb
84
+ - lib/undertow/version.rb
85
+ - undertow.gemspec
86
+ homepage: https://github.com/nallenscott/undertow
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ source_code_uri: https://github.com/nallenscott/undertow
91
+ changelog_uri: https://github.com/nallenscott/undertow/blob/main/CHANGELOG.md
92
+ bug_tracker_uri: https://github.com/nallenscott/undertow/issues
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '3.0'
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.4.19
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: Buffered, dependency-aware change propagation for ActiveRecord models
112
+ test_files: []