undertow 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53f9ee409c2585ff551f21884f10e8eec276ee750e6f3c12109280cc79fdd934
4
- data.tar.gz: e343c5be340f08b271e5851a91c0a3c5f363ce28357b5deb83657ae5c242f494
3
+ metadata.gz: 0b2c91ae2e1c3eafff3e52438ca4a8c2e2856fd83c2c1097708d3e7f2ed457be
4
+ data.tar.gz: 385394ce28a3034f17d4e89d3d00528931f38c44a180b338c5bfb43c83b4877b
5
5
  SHA512:
6
- metadata.gz: 28d46d6ddfb4e02ef12dcc001295e9f39034708af79ba12761553d61c32fe96d6d64769cdf45c9df8d3276ff19c9de0080b843ff54cc575e94f7a947db545c99
7
- data.tar.gz: 49c01b579d3797b755d52c077890753abba9defe3b49ca7e7871564c4ee31627328933a00e0fa0f3e031af16f5e2cb54354516dc277bc87dfc18e68cd464379c
6
+ metadata.gz: 57614c035d133873d7f3c9b881bc6c59de51c0f2add8bc860eb059f955bdd4cc7a57576e99df1085576db5b64fbdfd7c6b4376136093b46ed259e2e70dc3f87b
7
+ data.tar.gz: 20cca40fdfe6459d18b264050ce5db678d6d94e537d3bd19a7fbe3a61fb5b7e32bb13bd859a88d263f20d8df4473cc178d6796470d609add092d66ca60229da9
@@ -1,98 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Low-level set operations used by Trackable callbacks and DrainJob.
5
+ # Delegates to the configured store adapter. All methods are no-ops when
6
+ # tracking is disabled.
6
7
  module Buffer
7
8
  class << self
8
9
  def push_pending(model_name, ids)
9
10
  return if Undertow.tracking_disabled?
10
11
 
11
- with_redis do |r|
12
- r.sadd(Registry.pending_key(model_name), ids)
13
- r.sadd(Registry::MODELS_KEY, model_name)
14
- end
12
+ store.add_members(Registry.pending_key(model_name), ids)
13
+ store.add_members(Registry::MODELS_KEY, model_name)
15
14
  end
16
15
 
17
16
  def push_deleted(model_name, ids)
18
17
  return if Undertow.tracking_disabled?
19
18
 
20
- with_redis do |r|
21
- r.sadd(Registry.deleted_key(model_name), ids)
22
- r.sadd(Registry::MODELS_KEY, model_name)
23
- end
19
+ store.add_members(Registry.deleted_key(model_name), ids)
20
+ store.add_members(Registry::MODELS_KEY, model_name)
24
21
  end
25
22
 
26
23
  def pop_pending(model_name, count)
27
- with_redis { |r| r.spop(Registry.pending_key(model_name), count) } || []
24
+ store.pop_members(Registry.pending_key(model_name), count)
28
25
  end
29
26
 
30
27
  def pop_deleted(model_name, count)
31
- with_redis { |r| r.spop(Registry.deleted_key(model_name), count) } || []
28
+ store.pop_members(Registry.deleted_key(model_name), count)
32
29
  end
33
30
 
34
31
  def pending_model_names
35
- with_redis { |r| r.smembers(Registry::MODELS_KEY) } || []
32
+ store.members(Registry::MODELS_KEY)
36
33
  end
37
34
 
38
35
  def deregister_model(model_name)
39
- with_redis { |r| r.srem(Registry::MODELS_KEY, model_name) }
36
+ store.remove_member(Registry::MODELS_KEY, model_name)
40
37
  end
41
38
 
42
39
  def reregister_model(model_name)
43
- with_redis { |r| r.sadd(Registry::MODELS_KEY, model_name) }
40
+ store.add_members(Registry::MODELS_KEY, model_name)
44
41
  end
45
42
 
46
43
  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
44
+ store.member_count(Registry.pending_key(model_name)) +
45
+ store.member_count(Registry.deleted_key(model_name))
51
46
  end
52
47
 
53
48
  def restore_pending(model_name, ids)
54
- with_redis { |r| r.sadd(Registry.pending_key(model_name), ids) } if ids.any?
49
+ store.add_members(Registry.pending_key(model_name), ids) if ids.any?
55
50
  end
56
51
 
57
52
  def restore_deleted(model_name, ids)
58
- with_redis { |r| r.sadd(Registry.deleted_key(model_name), ids) } if ids.any?
53
+ store.add_members(Registry.deleted_key(model_name), ids) if ids.any?
59
54
  end
60
55
 
61
56
  def pending?
62
- with_redis { |r| r.scard(Registry::MODELS_KEY) > 0 } || false
57
+ store.member_count(Registry::MODELS_KEY).positive?
63
58
  end
64
59
 
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
60
  def acquire_drain_lock(ttl: 30)
69
61
  lock_key = Undertow.configuration.drain_lock_key
70
62
  return true unless lock_key
71
63
 
72
- with_redis { |r| r.set(lock_key, '1', nx: true, ex: ttl) } || false
64
+ store.lock_acquire(lock_key, ttl: ttl)
73
65
  end
74
66
 
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
67
  def release_drain_lock
78
68
  lock_key = Undertow.configuration.drain_lock_key
79
69
  return unless lock_key
80
70
 
81
- with_redis { |r| r.del(lock_key) }
71
+ store.lock_release(lock_key)
82
72
  end
83
73
 
84
74
  private
85
75
 
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
76
+ def store
77
+ Undertow.configuration.store!
96
78
  end
97
79
  end
98
80
  end
@@ -2,12 +2,21 @@
2
2
 
3
3
  module Undertow
4
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:
5
+ # A store adapter (an instance of Undertow::Store::Base). Injected by the
6
+ # host application:
7
7
  #
8
- # Undertow.configure { |c| c.redis = Redis.new(url: ENV['REDIS_URL']) }
8
+ # # Redis
9
+ # Undertow.configure do |c|
10
+ # c.store = Undertow::Store::RedisStore.new(Redis.new(url: ENV['REDIS_URL']))
11
+ # end
9
12
  #
10
- attr_accessor :redis
13
+
14
+ # # In-memory (test / single-process dev)
15
+ # Undertow.configure do |c|
16
+ # c.store = Undertow::Store::MemoryStore.new
17
+ # end
18
+ #
19
+ attr_accessor :store
11
20
 
12
21
  # Maximum number of IDs to pop from the buffer per drain per model.
13
22
  attr_accessor :max_batch
@@ -15,20 +24,21 @@ module Undertow
15
24
  # ActiveJob queue to use for DrainJob.
16
25
  attr_accessor :queue_name
17
26
 
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.
27
+ # Key used for the distributed drain lock. The scheduler acquires this lock
28
+ # before enqueueing DrainJob; the job releases it immediately on start so
29
+ # new work arriving mid-drain gets its own job on the next tick.
21
30
  # Set to nil to disable lock management entirely.
22
31
  attr_accessor :drain_lock_key
23
32
 
24
33
  def initialize
34
+ @store = Undertow::Store::MemoryStore.new
25
35
  @max_batch = 1_000
26
36
  @queue_name = :undertow
27
37
  @drain_lock_key = 'undertow:drain:lock'
28
38
  end
29
39
 
30
- def redis!
31
- redis or raise 'Undertow.configuration.redis is not set'
40
+ def store!
41
+ store or raise 'Undertow.configuration.store is not set'
32
42
  end
33
43
  end
34
44
  end
@@ -5,8 +5,8 @@ module Undertow
5
5
  # each model's configured on_drain handler.
6
6
  #
7
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: })
8
+ # drain.undertow , after a successful on_drain call ({ model:, ids:, deleted_ids: })
9
+ # error.undertow , when on_drain raises ({ model:, exception: })
10
10
  class DrainJob < ActiveJob::Base
11
11
  queue_as { Undertow.configuration.queue_name }
12
12
 
@@ -26,7 +26,7 @@ module Undertow
26
26
  def drain_model(model_name)
27
27
  max = Undertow.configuration.max_batch
28
28
 
29
- # Deregister before popping any concurrent push will re-add the model,
29
+ # Deregister before popping, any concurrent push will re-add the model,
30
30
  # preventing the race where srem fires after a concurrent sadd.
31
31
  Buffer.deregister_model(model_name)
32
32
 
@@ -44,8 +44,8 @@ module Undertow
44
44
  config.on_drain.call(model_name, ids, deleted_ids)
45
45
 
46
46
  ActiveSupport::Notifications.instrument('drain.undertow', {
47
- model: model_name,
48
- ids: ids,
47
+ model: model_name,
48
+ ids: ids,
49
49
  deleted_ids: deleted_ids
50
50
  })
51
51
  rescue StandardError => e
data/lib/undertow/dsl.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Undertow
4
4
  # Class-level DSL extended onto ActiveRecord::Base by the Railtie. Any model
5
5
  # that calls these methods automatically registers itself with Undertow and
6
- # gets Trackable behavior wired in at boot no include needed.
6
+ # gets Trackable behavior wired in at boot, no include needed.
7
7
  #
8
8
  # class Activity < ApplicationRecord
9
9
  # undertow_on_drain ->(model_name, ids, deleted_ids) { ActivityReindexJob.perform_later(ids, deleted_ids) }
@@ -30,10 +30,10 @@ module Undertow
30
30
  raise ArgumentError, 'provide exactly one of foreign_key: or resolver:' unless foreign_key.nil? ^ resolver.nil?
31
31
 
32
32
  _undertow_config.dependencies << {
33
- association: association,
34
- foreign_key: foreign_key,
35
- resolver: resolver,
36
- watched_columns: watched_columns
33
+ association: association,
34
+ foreign_key: foreign_key,
35
+ resolver: resolver,
36
+ watched_columns: watched_columns
37
37
  }.freeze
38
38
  _undertow_ensure_trackable!
39
39
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ module Store
5
+ # Abstract base class for Undertow store adapters.
6
+ #
7
+ # Concrete adapters must implement all methods defined here.
8
+ # Buffer delegates all operations to the configured store, so adapters
9
+ # are the only place that knows about the underlying backend.
10
+ class Base
11
+ # Add members to the set at key.
12
+ def add_members(key, members)
13
+ raise NotImplementedError, "#{self.class}#add_members is not implemented"
14
+ end
15
+
16
+ # Remove a single member from the set at key.
17
+ def remove_member(key, member)
18
+ raise NotImplementedError, "#{self.class}#remove_member is not implemented"
19
+ end
20
+
21
+ # Return all members of the set at key.
22
+ def members(key)
23
+ raise NotImplementedError, "#{self.class}#members is not implemented"
24
+ end
25
+
26
+ # Atomically remove and return up to count members from the set at key.
27
+ def pop_members(key, count)
28
+ raise NotImplementedError, "#{self.class}#pop_members is not implemented"
29
+ end
30
+
31
+ # Return the number of members in the set at key.
32
+ def member_count(key)
33
+ raise NotImplementedError, "#{self.class}#member_count is not implemented"
34
+ end
35
+
36
+ # Attempt to acquire a lock at key with the given TTL in seconds.
37
+ # Returns true if the lock was acquired, false if already held.
38
+ def lock_acquire(key, ttl:)
39
+ raise NotImplementedError, "#{self.class}#lock_acquire is not implemented"
40
+ end
41
+
42
+ # Release the lock at key.
43
+ def lock_release(key)
44
+ raise NotImplementedError, "#{self.class}#lock_release is not implemented"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Undertow
6
+ module Store
7
+ # Store adapter backed by in-process memory.
8
+ #
9
+ # Uses Mutex-protected Ruby Sets for thread safety. Intended for use in
10
+ # test environments and single-process development setups.
11
+ #
12
+ # Lock operations are no-ops, there is no scheduler race in a single process.
13
+ #
14
+ # Undertow.configure do |c|
15
+ # c.store = Undertow::Store::MemoryStore.new
16
+ # end
17
+ #
18
+ # WARNING: State is not shared across processes. Do not use in multi-process
19
+ # or multi-dyno deployments.
20
+ class MemoryStore < Base
21
+ def initialize
22
+ super
23
+ @sets = Hash.new { |h, k| h[k] = Set.new }
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def add_members(key, members)
28
+ @mutex.synchronize { @sets[key].merge(Array(members)) }
29
+ end
30
+
31
+ def remove_member(key, member)
32
+ @mutex.synchronize { @sets[key].delete(member) }
33
+ end
34
+
35
+ def members(key)
36
+ @mutex.synchronize { @sets[key].to_a }
37
+ end
38
+
39
+ def pop_members(key, count)
40
+ @mutex.synchronize do
41
+ members = @sets[key].first(count)
42
+ members.each { |m| @sets[key].delete(m) }
43
+ members
44
+ end
45
+ end
46
+
47
+ def member_count(key)
48
+ @mutex.synchronize { @sets[key].size }
49
+ end
50
+
51
+ # No-op, single process, no scheduler race possible.
52
+ def lock_acquire(*)
53
+ true
54
+ end
55
+
56
+ # No-op.
57
+ def lock_release(key)
58
+ # nothing to do
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Undertow
4
+ module Store
5
+ # Store adapter backed by Redis.
6
+ #
7
+ # Accepts a Redis client or a connection pool (any object responding to #with).
8
+ #
9
+ # Undertow.configure do |c|
10
+ # c.store = Undertow::Store::RedisStore.new(Redis.new(url: ENV['REDIS_URL']))
11
+ # end
12
+ #
13
+ class RedisStore < Base
14
+ def initialize(client)
15
+ super()
16
+ @client = client
17
+ end
18
+
19
+ def add_members(key, members)
20
+ with_redis { |r| r.sadd(key, members) }
21
+ end
22
+
23
+ def remove_member(key, member)
24
+ with_redis { |r| r.srem(key, member) }
25
+ end
26
+
27
+ def members(key)
28
+ with_redis { |r| r.smembers(key) } || []
29
+ end
30
+
31
+ def pop_members(key, count)
32
+ with_redis { |r| r.spop(key, count) } || []
33
+ end
34
+
35
+ def member_count(key)
36
+ with_redis { |r| r.scard(key) } || 0
37
+ end
38
+
39
+ def lock_acquire(key, ttl:)
40
+ with_redis { |r| r.set(key, '1', nx: true, ex: ttl) } || false
41
+ end
42
+
43
+ def lock_release(key)
44
+ with_redis { |r| r.del(key) }
45
+ end
46
+
47
+ private
48
+
49
+ def with_redis
50
+ if @client.respond_to?(:with)
51
+ @client.with { |conn| yield conn }
52
+ else
53
+ yield @client
54
+ end
55
+ rescue Redis::BaseConnectionError, Redis::CommandError => e
56
+ Rails.logger.error("Undertow: Redis error: #{e.message}") if defined?(Rails)
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -11,14 +11,14 @@ module Undertow
11
11
 
12
12
  included do
13
13
  # Columns listed here suppress self-tracking when they are the *only*
14
- # things that changed prevents feedback loops from columns updated by
14
+ # things that changed, prevents feedback loops from columns updated by
15
15
  # the drain handler itself.
16
16
  class_attribute :_undertow_ignored_columns, default: [], instance_writer: false
17
17
  end
18
18
 
19
19
  class_methods do
20
20
  # Called by the Railtie after all models/associations are loaded.
21
- # Idempotent safe to call multiple times (e.g. in reloading environments).
21
+ # Idempotent, safe to call multiple times (e.g. in reloading environments).
22
22
  def register_undertow_callbacks!(config)
23
23
  return if @_undertow_callbacks_registered
24
24
 
@@ -43,7 +43,7 @@ module Undertow
43
43
  return unless dep_class
44
44
 
45
45
  root_class = self
46
- watched = dep[:watched_columns].presence # [] treated same as nil watch all
46
+ watched = dep[:watched_columns].presence # [] treated same as nil, watch all
47
47
 
48
48
  resolver = dep[:resolver] || begin
49
49
  fk = dep[:foreign_key]
@@ -59,20 +59,20 @@ module Undertow
59
59
 
60
60
  # Skip create/update callback when watched_columns is set and none changed.
61
61
  # Note: saved_changes is empty when touched via belongs_to touch: true (bypasses
62
- # dirty tracking) that correctly falls through to skip here.
62
+ # dirty tracking), that correctly falls through to skip here.
63
63
  dep_class.after_commit on: %i[create update] do
64
64
  next if watched && (saved_changes.keys & watched).none?
65
65
 
66
66
  push_pending.call(self)
67
67
  end
68
68
 
69
- # Dep destroyed reindex surviving root records. SoftDeletable calls
69
+ # Dep destroyed, reindex surviving root records. SoftDeletable calls
70
70
  # run_callbacks(:destroy), which fires after_destroy, but update_columns does NOT
71
71
  # trigger after_commit, so scoping after_commit to [:create, :update] above
72
72
  # ensures destroy commits don't double-fire.
73
73
  dep_class.after_destroy { push_pending.call(self) }
74
74
 
75
- # Dep restored after_restore is the only hook that fires because restore!
75
+ # Dep restored, after_restore is the only hook that fires because restore!
76
76
  # uses update_columns, bypassing after_commit.
77
77
  if dep_class.respond_to?(:after_restore)
78
78
  dep_class.after_restore { push_pending.call(self) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Undertow
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/undertow.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support'
4
3
  require 'active_record'
4
+ require 'active_support'
5
5
 
6
6
  require_relative 'undertow/version'
7
7
  require_relative 'undertow/configuration'
8
+ require_relative 'undertow/store/base'
9
+ require_relative 'undertow/store/redis_store'
10
+ require_relative 'undertow/store/memory_store'
8
11
  require_relative 'undertow/registry'
9
12
  require_relative 'undertow/buffer'
10
13
  require_relative 'undertow/dsl'
@@ -37,7 +40,7 @@ module Undertow
37
40
  end
38
41
 
39
42
  # Called from the host application's scheduler on each tick. Checks for
40
- # pending work, acquires the drain lock, and enqueues DrainJob no other
43
+ # pending work, acquires the drain lock, and enqueues DrainJob, no other
41
44
  # wiring required:
42
45
  #
43
46
  # every(1.second, 'undertow') { Undertow.tick }
data/undertow.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
13
13
 
14
14
  spec.metadata = {
15
15
  'source_code_uri' => 'https://github.com/nallenscott/undertow',
16
- 'changelog_uri' => 'https://github.com/nallenscott/undertow/blob/main/CHANGELOG.md',
16
+ 'changelog_uri' => 'https://github.com/nallenscott/undertow/blob/main/CHANGELOG.md',
17
17
  'bug_tracker_uri' => 'https://github.com/nallenscott/undertow/issues'
18
18
  }
19
19
 
@@ -24,5 +24,4 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency 'activerecord', '~> 7.0'
25
25
  spec.add_dependency 'activesupport', '~> 7.0'
26
26
  spec.add_dependency 'activejob', '~> 7.0'
27
- spec.add_dependency 'redis', '~> 5.0'
28
27
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: undertow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Allen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -52,20 +52,6 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
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
55
  description:
70
56
  email:
71
57
  - hello@nallenscott.com
@@ -80,6 +66,9 @@ files:
80
66
  - lib/undertow/dsl.rb
81
67
  - lib/undertow/railtie.rb
82
68
  - lib/undertow/registry.rb
69
+ - lib/undertow/store/base.rb
70
+ - lib/undertow/store/memory_store.rb
71
+ - lib/undertow/store/redis_store.rb
83
72
  - lib/undertow/trackable.rb
84
73
  - lib/undertow/version.rb
85
74
  - undertow.gemspec