demo_mode 3.5.0 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a112b6f295027661f77ad3e708d373f6e24483ee562aa23307787c29f5cb45a9
4
- data.tar.gz: 61c4948ea423a70295d48ed8b62a2d7de6359efa30560a8b40faa6831e2ada3a
3
+ metadata.gz: 5b2e916a3b2b58e6f3cbd027fad72a91b8cb550443a50e64988caa0dbd1ff6fa
4
+ data.tar.gz: ff00e7655ecf6d9e5ae118b84542bf97ec6e568143e4c2961ef42b1c28e0894c
5
5
  SHA512:
6
- metadata.gz: 85fc9433912c6d7b9272cb9e7c526b9fdb7f3326784d6b65ba0b42dd9b9b022f643b79c7f7f5a299af5ee9bddf6c3594414a010fb1dc2bf670642a25b4c363c2
7
- data.tar.gz: e05fed5948d72ef9cdd31bf01be8a9589648cdee0da4c6e24c080e1f82fab3cf2c669b6eb47dab470e730b8adbce943cd85c76c3c160f3bc1ed6bc458aaffad3
6
+ metadata.gz: b240952b7f353fff7cb723a409cc07806ffc477d83640968d486d4050f69d37d51777fa93e603f3e41b869226314cffba020fc7df96f640220dd5389591c0895
7
+ data.tar.gz: ee5ebfd9364b612abaa6dbff7b0f6d6f0e3624dbd7383486282c9504e85d4d96ee54c39c76b143f4d4f10cb06f557ee0355595b57ca35faf5384ca951f6d883f
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CleverSequence
4
+ module InMemoryBackend
5
+ class << self
6
+ def nextval(klass, attribute, block)
7
+ key = [klass.name, attribute.to_s]
8
+ sequence_state[key] = current_value(klass, attribute, block, key) + 1
9
+ end
10
+
11
+ def reset!
12
+ @sequence_state = {}
13
+ end
14
+
15
+ def starting_value(klass, attribute, block)
16
+ column_name = resolve_column_name(klass, attribute)
17
+
18
+ if column_exists?(klass, column_name)
19
+ LowerBoundFinder.new(klass, column_name, block).lower_bound
20
+ else
21
+ 0
22
+ end
23
+ end
24
+
25
+ def with_sequence_adjustment(**)
26
+ # No-op for InMemoryBackend. After reset!, nextval already
27
+ # recalculates from the database via starting_value/LowerBoundFinder,
28
+ # which finds the correct lower bound past existing data.
29
+ yield
30
+ end
31
+
32
+ def sequence_state
33
+ @sequence_state ||= {}
34
+ end
35
+
36
+ private
37
+
38
+ def current_value(klass, attribute, block, key)
39
+ sequence_state[key] || starting_value(klass, attribute, block)
40
+ end
41
+
42
+ def resolve_column_name(klass, attribute)
43
+ klass.attribute_aliases[attribute.to_s] || attribute.to_s
44
+ end
45
+
46
+ def column_exists?(klass, column_name)
47
+ klass && klass.column_names.include?(column_name)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,19 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class CleverSequence
4
- LowerBoundFinder = Struct.new(:klass, :column_name, :block) do
5
- def lower_bound(current = 1, lower = 0, upper = Float::INFINITY)
4
+ class LowerBoundFinder
5
+ attr_reader :klass, :column_name, :block
6
+
7
+ def initialize(klass, column_name, block)
8
+ @klass = klass
9
+ @column_name = column_name
10
+ @block = block
11
+ end
12
+
13
+ def lower_bound(hint: nil)
14
+ with_lock do
15
+ start = hint && hint >= 1 ? hint : 1
16
+ # If the hint overshoots the actual data, return it directly.
17
+ # The hint is a previously-known high-water mark, so it's a valid
18
+ # lower bound. Callers pass the result through GREATEST against the
19
+ # PG sequence, so a higher value is always safe and avoids a costly
20
+ # binary search back down to data that won't be used anyway.
21
+ next hint if start > 1 && !exists?(start)
22
+
23
+ _lower_bound(start, 0, Float::INFINITY)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def with_lock(&)
30
+ if ActiveRecord::Base.connection.adapter_name.casecmp?('postgresql')
31
+ ActiveRecord::Base.with_transactional_lock("lower-bound-#{klass}-#{column_name}", &)
32
+ else
33
+ yield
34
+ end
35
+ end
36
+
37
+ def _lower_bound(current, lower, upper)
6
38
  if exists?(current)
7
- lower_bound(next_between(current, upper), [current, lower].max, upper)
39
+ # When upper is at most current + 1, we know current is the highest
40
+ # existing value (upper is always a known-false or Infinity bound).
41
+ # next_between would return current due to integer division, causing
42
+ # infinite recursion, so return early.
43
+ return current if upper <= current + 1
44
+
45
+ _lower_bound(next_between(current, upper), [current, lower].max, upper)
8
46
  elsif current - lower > 1
9
- lower_bound(next_between(lower, current), lower, [current, upper].min)
47
+ _lower_bound(next_between(lower, current), lower, [current, upper].min)
10
48
  else # current should == lower + 1
11
49
  lower
12
50
  end
13
51
  end
14
52
 
15
- private
16
-
17
53
  def next_between(lower, upper)
18
54
  [((lower + 1) / 2) + (upper / 2), lower * 2].min
19
55
  end
@@ -24,35 +24,35 @@ class CleverSequence
24
24
  end
25
25
 
26
26
  class << self
27
+ def reset!
28
+ Thread.current[:clever_sequence_cache] = {}
29
+ end
30
+
31
+ def starting_value(klass, attribute, block)
32
+ calculate_sequence_value(klass, attribute, block)
33
+ end
34
+
35
+ def with_sequence_adjustment(last_values: {})
36
+ previous = Thread.current[:clever_sequence_adjustment_enabled]
37
+ previous_last_values = Thread.current[:clever_sequence_last_values]
38
+ log "[DemoMode] Enabling sequence adjustment for retry"
39
+ Thread.current[:clever_sequence_adjustment_enabled] = true
40
+ Thread.current[:clever_sequence_last_values] = last_values
41
+ yield
42
+ ensure
43
+ Thread.current[:clever_sequence_adjustment_enabled] = previous
44
+ Thread.current[:clever_sequence_last_values] = previous_last_values
45
+ log "[DemoMode] Disabled sequence adjustment"
46
+ end
47
+
27
48
  def nextval(klass, attribute, block)
28
49
  name = sequence_name(klass, attribute)
50
+ log "[DemoMode] nextval called for #{klass.name}##{attribute} (sequence: #{name})"
29
51
 
30
52
  if sequence_exists?(name)
31
- sequence_cache[name] = SequenceResult::Exists.new(name)
32
-
33
- result = ActiveRecord::Base.connection.execute(
34
- "SELECT nextval('#{name}')",
35
- )
36
- result.first['nextval'].to_i
53
+ nextval_from_sequence(name, klass, attribute, block)
37
54
  else
38
- start_value = calculate_sequence_value(klass, attribute, block)
39
-
40
- sequence_cache[name] = SequenceResult::Missing.new(
41
- sequence_name: name,
42
- klass: klass,
43
- attribute: attribute,
44
- calculated_start_value: start_value + 1,
45
- )
46
-
47
- if CleverSequence.enforce_sequences_exist
48
- raise SequenceNotFoundError.new(
49
- sequence_name: name,
50
- klass: klass,
51
- attribute: attribute,
52
- )
53
- else
54
- start_value + 1
55
- end
55
+ nextval_without_sequence(name, klass, attribute, block)
56
56
  end
57
57
  end
58
58
 
@@ -61,36 +61,122 @@ class CleverSequence
61
61
  attr = attribute.to_s.gsub(/[^a-z0-9_]/i, '_')
62
62
  # Handle PostgreSQL identifier limit:
63
63
  limit = (63 - SEQUENCE_PREFIX.length) / 2
64
- "#{SEQUENCE_PREFIX}#{table[0, limit]}_#{attr[0, limit]}"
64
+ # Lowercase to avoid PostgreSQL case-sensitivity issues with unquoted identifiers
65
+ "#{SEQUENCE_PREFIX}#{table[0, limit]}_#{attr[0, limit]}".downcase
65
66
  end
66
67
 
67
68
  def sequence_cache
68
- @sequence_cache ||= {}
69
+ Thread.current[:clever_sequence_cache] ||= {}
69
70
  end
70
71
 
71
72
  private
72
73
 
74
+ def log(message, level: DemoMode.log_level)
75
+ Rails.logger.public_send(level, message)
76
+ end
77
+
78
+ def nextval_from_sequence(name, klass, attribute, block)
79
+ # On first use with adjustment enabled, ensure sequence is past existing data
80
+ if adjust_sequences_enabled? && !sequence_cache[name].is_a?(SequenceResult::Exists)
81
+ log "[DemoMode] Sequence adjustment enabled, adjusting #{name}"
82
+ adjust_sequence_if_needed(name, klass, attribute, block)
83
+ end
84
+ sequence_cache[name] = SequenceResult::Exists.new(name)
85
+
86
+ result = ActiveRecord::Base.connection.execute(
87
+ "SELECT nextval('#{name}')",
88
+ )
89
+ value = result.first['nextval'].to_i
90
+ log "[DemoMode] nextval for #{klass.name}##{attribute} returned #{value}"
91
+ value
92
+ end
93
+
94
+ def nextval_without_sequence(name, klass, attribute, block)
95
+ next_value = calculate_next_missing_value(name, klass, attribute, block)
96
+
97
+ if CleverSequence.enforce_sequences_exist
98
+ log "[DemoMode] Raising SequenceNotFoundError for #{name}", level: :warn
99
+ raise SequenceNotFoundError.new(
100
+ sequence_name: name, klass: klass, attribute: attribute,
101
+ )
102
+ else
103
+ log "[DemoMode] nextval returning #{next_value} (fallback, #{name} missing)"
104
+ next_value
105
+ end
106
+ end
107
+
108
+ def calculate_next_missing_value(name, klass, attribute, block)
109
+ cached = sequence_cache[name]
110
+
111
+ next_value = if cached.is_a?(SequenceResult::Missing)
112
+ cached.calculated_start_value + 1
113
+ else
114
+ calculate_sequence_value(klass, attribute, block) + 1
115
+ end
116
+
117
+ sequence_cache[name] = SequenceResult::Missing.new(
118
+ sequence_name: name, klass: klass,
119
+ attribute: attribute, calculated_start_value: next_value
120
+ )
121
+
122
+ next_value
123
+ end
124
+
125
+ def adjust_sequences_enabled?
126
+ Thread.current[:clever_sequence_adjustment_enabled]
127
+ end
128
+
73
129
  def sequence_exists?(sequence_name)
74
130
  if sequence_cache.key?(sequence_name)
75
- case sequence_cache[sequence_name]
76
- when SequenceResult::Exists
77
- return true
78
- else
79
- return false
80
- end
131
+ exists = sequence_cache[sequence_name].is_a?(SequenceResult::Exists)
132
+ log "[DemoMode] Sequence #{sequence_name} #{exists ? 'exists' : 'missing'} (cached)"
133
+ return exists
81
134
  end
82
135
 
83
- ActiveRecord::Base.connection.execute(
84
- "SELECT 1 FROM information_schema.sequences WHERE sequence_name = '#{sequence_name}' LIMIT 1",
136
+ exists = ActiveRecord::Base.connection.execute(
137
+ "SELECT 1 FROM information_schema.sequences " \
138
+ "WHERE sequence_name = '#{sequence_name}' LIMIT 1",
85
139
  ).any?
140
+ log "[DemoMode] Sequence #{sequence_name} #{exists ? 'found' : 'not found'}"
141
+ exists
86
142
  end
87
143
 
88
- def calculate_sequence_value(klass, attribute, block)
144
+ def calculate_sequence_value(klass, attribute, block, hint: nil)
89
145
  column_name = klass.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
90
- return 0 unless klass.column_names.include?(column_name)
146
+ unless klass.column_names.include?(column_name)
147
+ log "[DemoMode] Column #{column_name} not found on #{klass.name}", level: :warn
148
+ return 0
149
+ end
150
+
151
+ value = LowerBoundFinder.new(klass, column_name, block).lower_bound(hint:)
152
+ log "[DemoMode] Calculated sequence value for #{klass.name}##{attribute}: #{value} (hint: #{hint || 'none'})"
153
+ value
154
+ end
155
+
156
+ def hint_for(klass, attribute)
157
+ last_values = Thread.current[:clever_sequence_last_values]
158
+ last_values && last_values[[klass.name, attribute.to_s]]
159
+ end
160
+
161
+ def adjust_sequence_if_needed(sequence_name, klass, attribute, block)
162
+ ActiveRecord::Base.with_transactional_lock("adjust-sequence-#{sequence_name}") do
163
+ hint = hint_for(klass, attribute)
164
+ max_value = calculate_sequence_value(klass, attribute, block, hint:)
165
+ if max_value < 1
166
+ log "[DemoMode] No adjustment needed for #{sequence_name}"
167
+ return
168
+ end
91
169
 
92
- ActiveRecord::Base.with_transactional_lock("lower-bound-#{klass}-#{column_name}") do
93
- LowerBoundFinder.new(klass, column_name, block).lower_bound
170
+ log "[DemoMode] Adjusting #{sequence_name} to at least #{max_value}"
171
+ # setval sets the sequence's last_value. With the default 3rd argument (true),
172
+ # the next nextval() will return last_value + 1.
173
+ # We only want to advance (never go backwards), so we use GREATEST.
174
+ result = ActiveRecord::Base.connection.execute(<<~SQL.squish)
175
+ SELECT setval('#{sequence_name}',
176
+ GREATEST(#{max_value}, (SELECT last_value FROM #{sequence_name})))
177
+ SQL
178
+ new_last_value = result.first['setval'].to_i
179
+ log "[DemoMode] #{sequence_name} adjusted to #{new_last_value}"
94
180
  end
95
181
  end
96
182
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'clever_sequence/lower_bound_finder'
4
+ require_relative 'clever_sequence/in_memory_backend'
4
5
  require_relative 'clever_sequence/postgres_backend'
5
6
 
6
7
  class CleverSequence
@@ -9,15 +10,32 @@ class CleverSequence
9
10
  cattr_accessor(:sequences) { {} }
10
11
  cattr_accessor(:use_database_sequences) { false }
11
12
  cattr_accessor(:enforce_sequences_exist) { false }
13
+ cattr_accessor(:retry_on_uniqueness_violation) { true }
12
14
 
13
15
  class << self
14
16
  alias use_database_sequences? use_database_sequences
15
17
  alias enforce_sequences_exist? enforce_sequences_exist
18
+ alias retry_on_uniqueness_violation? retry_on_uniqueness_violation
19
+
20
+ def backend
21
+ use_database_sequences? ? PostgresBackend : InMemoryBackend
22
+ end
16
23
 
17
24
  def reset!
25
+ backend.reset!
18
26
  sequences.each_value(&:reset!)
19
27
  end
20
28
 
29
+ def with_sequence_adjustment(&)
30
+ last_values = snapshot_last_values
31
+ reset!
32
+ backend.with_sequence_adjustment(last_values:, &)
33
+ end
34
+
35
+ def snapshot_last_values
36
+ sequences.transform_values { |seq| seq.send(:last_value) }.compact
37
+ end
38
+
21
39
  def next(klass, name)
22
40
  lookup(klass, name)&.next
23
41
  end
@@ -47,41 +65,48 @@ class CleverSequence
47
65
  end
48
66
 
49
67
  def next
50
- @last_value = if CleverSequence.use_database_sequences?
51
- PostgresBackend.nextval(klass, attribute, block)
68
+ value = if klass
69
+ self.class.backend.nextval(klass, attribute, block)
52
70
  else
53
- last_value + 1
71
+ (last_value || 0) + 1
54
72
  end
73
+ self.last_value = value
55
74
  last
56
75
  end
57
76
 
58
77
  def last
59
- block.call(last_value)
78
+ block.call(last_value || (klass ? self.class.backend.starting_value(klass, attribute, block) : 0))
60
79
  end
61
80
 
62
81
  def reset!
63
- remove_instance_variable(:@last_value) if instance_variable_defined?(:@last_value)
82
+ clear_last_value
64
83
  end
65
84
 
66
85
  private
67
86
 
68
87
  def last_value
69
- @last_value ||= starting_value
70
- end
71
-
72
- def starting_value
73
- if column_exists?
74
- LowerBoundFinder.new(klass, column_name, block).lower_bound
88
+ if klass
89
+ Thread.current[:clever_sequence_last_value]&.dig(klass.name, attribute)
75
90
  else
76
- 0
91
+ @last_value
77
92
  end
78
93
  end
79
94
 
80
- def column_name
81
- klass.attribute_aliases[attribute] || attribute
95
+ def last_value=(value)
96
+ if klass
97
+ Thread.current[:clever_sequence_last_value] ||= {}
98
+ Thread.current[:clever_sequence_last_value][klass.name] ||= {}
99
+ Thread.current[:clever_sequence_last_value][klass.name][attribute] = value
100
+ else
101
+ @last_value = value
102
+ end
82
103
  end
83
104
 
84
- def column_exists?
85
- klass && klass.column_names.include?(column_name)
105
+ def clear_last_value
106
+ if klass
107
+ Thread.current[:clever_sequence_last_value]&.[](klass.name)&.delete(attribute)
108
+ else
109
+ @last_value = nil
110
+ end
86
111
  end
87
112
  end
@@ -13,6 +13,7 @@ module DemoMode
13
13
  configurable_value(:signinable_username_method) { :email }
14
14
  configurable_value(:personas_path) { 'config/personas' }
15
15
  configurable_value(:session_timeout) { 30.minutes }
16
+ configurable_value(:log_level) { :debug }
16
17
  configurable_boolean(:display_credentials)
17
18
  configurations << :stylesheets
18
19
  configurations << :logo
@@ -63,16 +63,32 @@ module DemoMode
63
63
  end
64
64
 
65
65
  def generate!(variant: :default, password: nil, options: {})
66
+ retried = false
66
67
  ActiveSupport::Notifications.instrument('demo_mode.persona.generate', name: name, variant: variant) do
67
68
  variant = variants[variant]
68
69
  CleverSequence.reset! if defined?(CleverSequence)
69
70
  DemoMode.current_password = password if password
70
71
  DemoMode.around_persona_generation.call(variant.signinable_generator, **options)
72
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
73
+ raise if retried || !should_retry_with_sequence_adjustment?(e)
74
+
75
+ retried = true
76
+ CleverSequence.with_sequence_adjustment do
77
+ DemoMode.around_persona_generation.call(variant.signinable_generator, **options)
78
+ end
71
79
  ensure
72
80
  DemoMode.current_password = nil
73
81
  end
74
82
  end
75
83
 
84
+ def should_retry_with_sequence_adjustment?(error)
85
+ return false unless defined?(CleverSequence)
86
+ return false unless CleverSequence.retry_on_uniqueness_violation?
87
+
88
+ Rails.logger.warn("[DemoMode] Uniqueness violation during persona generation, retrying with sequence adjustment: #{error.message}")
89
+ true
90
+ end
91
+
76
92
  def callout(callout = true) # rubocop:disable Style/OptionalBooleanParameter
77
93
  @callout = callout
78
94
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DemoMode
4
- VERSION = '3.5.0'
4
+ VERSION = '3.6.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: demo_mode
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.0
4
+ version: 3.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-03-20 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: actionpack
@@ -338,6 +339,7 @@ files:
338
339
  - db/migrate/20250210222933_add_demo_mode_sessions_status.rb
339
340
  - lib/demo_mode.rb
340
341
  - lib/demo_mode/clever_sequence.rb
342
+ - lib/demo_mode/clever_sequence/in_memory_backend.rb
341
343
  - lib/demo_mode/clever_sequence/lower_bound_finder.rb
342
344
  - lib/demo_mode/clever_sequence/postgres_backend.rb
343
345
  - lib/demo_mode/cli.rb
@@ -366,6 +368,7 @@ licenses:
366
368
  metadata:
367
369
  rubygems_mfa_required: 'true'
368
370
  changelog_uri: https://github.com/Betterment/demo_mode/blob/main/CHANGELOG.md
371
+ post_install_message:
369
372
  rdoc_options: []
370
373
  require_paths:
371
374
  - lib
@@ -380,7 +383,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
380
383
  - !ruby/object:Gem::Version
381
384
  version: '0'
382
385
  requirements: []
383
- rubygems_version: 4.0.6
386
+ rubygems_version: 3.4.10
387
+ signing_key:
384
388
  specification_version: 4
385
389
  summary: A configurable demo mode for your Rails app.
386
390
  test_files: []