demo_mode 3.4.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: e0cf4bfb4f300ba2c592c20287bfbb36eba0670f4e36f41da729712366637917
4
- data.tar.gz: fc4ce80f27450f77d04b5f12668b32a2bb2d8b246919884a193810cbbd6776c1
3
+ metadata.gz: 5b2e916a3b2b58e6f3cbd027fad72a91b8cb550443a50e64988caa0dbd1ff6fa
4
+ data.tar.gz: ff00e7655ecf6d9e5ae118b84542bf97ec6e568143e4c2961ef42b1c28e0894c
5
5
  SHA512:
6
- metadata.gz: 59d84c5fec1c5971a5661cb19383b4a831e59378ddaa9958f17f0577453706f2fb58eee90d71b2fa1870b22287a519f59f910fa828e72600b75b1c98435a2e0f
7
- data.tar.gz: 857e22d08f59ed49830170b8b7aa5446c276dd13d44a86a1ae1e6fbeebf991cf53a15cc833059eaa8a243481d38a8644845b1476769a8475c21044ac2117cbca
6
+ metadata.gz: b240952b7f353fff7cb723a409cc07806ffc477d83640968d486d4050f69d37d51777fa93e603f3e41b869226314cffba020fc7df96f640220dd5389591c0895
7
+ data.tar.gz: ee5ebfd9364b612abaa6dbff7b0f6d6f0e3624dbd7383486282c9504e85d4d96ee54c39c76b143f4d4f10cb06f557ee0355595b57ca35faf5384ca951f6d883f
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
- Demo Mode
2
- =========
1
+ # Demo Mode
2
+
3
3
  [![Gem Version](https://badge.fury.io/rb/demo_mode.svg)](https://rubygems.org/gems/demo_mode)
4
4
  [![Tests](https://github.com/Betterment/demo_mode/actions/workflows/tests.yml/badge.svg)](https://github.com/Betterment/demo_mode/actions/workflows/tests.yml)
5
5
 
@@ -35,22 +35,23 @@ To learn more about how we use `demo_mode` at **Betterment**, check out :sparkle
35
35
 
36
36
  ## Table of Contents
37
37
 
38
- * [Getting Started](#getting-started)
39
- * [Installation](#installation)
40
- * [App-Specific Setup](#app-specific-setup)
41
- * [Defining Personas](#defining-personas)
42
- * [Customizing the Design](#customizing-the-design)
43
- * [Optional Features](#optional-features)
44
- * [The "Sign Up" Link](#the-sign-up-link)
45
- * [The "Display Credentials" feature](#the-display-credentials-feature)
46
- * [Developer CLI](#developer-cli)
47
- * [Callbacks](#callbacks)
48
- * [Non-User Personas](#non-user-personas)
49
- * [FactoryBot `sequence` extension](#factorybot-sequence-extension)
50
- * [Deploying a demo environment to the cloud](#deploying-a-demo-environment-to-the-cloud)
51
- * [How to avoid breaking your new "demo" env](#how-to-avoid-breaking-your-new-demo-env)
52
- * [How to Contribute](#how-to-contribute)
53
- * [Suggested Workflow](#suggested-workflow)
38
+ - [Getting Started](#getting-started)
39
+ - [Installation](#installation)
40
+ - [App-Specific Setup](#app-specific-setup)
41
+ - [Defining Personas](#defining-personas)
42
+ - [Customizing the Design](#customizing-the-design)
43
+ - [Optional Features](#optional-features)
44
+ - [The "Sign Up" Link](#the-sign-up-link)
45
+ - [The "Display Credentials" feature](#the-display-credentials-feature)
46
+ - [Developer CLI](#developer-cli)
47
+ - [Callbacks](#callbacks)
48
+ - [Non-User Personas](#non-user-personas)
49
+ - [FactoryBot `sequence` extension](#factorybot-sequence-extension)
50
+ - [Database-backed sequences](#database-backed-sequences)
51
+ - [Deploying a demo environment to the cloud](#deploying-a-demo-environment-to-the-cloud)
52
+ - [How to avoid breaking your new "demo" env](#how-to-avoid-breaking-your-new-demo-env)
53
+ - [How to Contribute](#how-to-contribute)
54
+ - [Suggested Workflow](#suggested-workflow)
54
55
 
55
56
  ## Getting Started
56
57
 
@@ -538,6 +539,38 @@ end
538
539
  conditionally disable any uniqueness validations (e.g.
539
540
  `validates ... unless DemoMode.enabled?`).
540
541
 
542
+ ### Database-backed sequences
543
+
544
+ By default, `CleverSequence` (used by the FactoryBot `sequence` extension) uses an in-memory Ruby counter. For production demo environments running multiple processes or requiring persistence across restarts, you can enable PostgreSQL-backed sequences:
545
+
546
+ ```ruby
547
+ DemoMode.configure do
548
+ CleverSequence.use_database_sequences = true
549
+ end
550
+ ```
551
+
552
+ This feature flag controls whether `CleverSequence` uses PostgreSQL native sequences or the existing Ruby-based counter, allowing for gradual rollout and easy rollback. By default, `use_database_sequences` is `false`.
553
+
554
+ You can check the current setting with:
555
+
556
+ ```ruby
557
+ CleverSequence.use_database_sequences? # => false (default)
558
+ ```
559
+
560
+ You can also enforce that database sequences exist before they are used. When enabled, `CleverSequence` will raise an error if a sequence is requested but the corresponding PostgreSQL SEQUENCE does not exist yet (prompting the engineer to run a migration that creates the SEQUENCE). When disabled (the default), `CleverSequence` will fall back to calculating the next sequence value based on existing database data:
561
+
562
+ ```ruby
563
+ DemoMode.configure do
564
+ CleverSequence.enforce_sequences_exist = true
565
+ end
566
+ ```
567
+
568
+ You can check this setting with:
569
+
570
+ ```ruby
571
+ CleverSequence.enforce_sequences_exist? # => false (default)
572
+ ```
573
+
541
574
  ## Deploying a demo environment to the cloud
542
575
 
543
576
  This gem truly shines when used to deploy a "demo" version of
@@ -652,6 +685,13 @@ you operate an "on call" process, engineers should be made aware
652
685
  that this demo environment _is_ a "production-like" environment
653
686
  and should expect "production-like" uptime guarantees.
654
687
 
688
+ We also emit an `ActiveSupport::Notifications` event
689
+ (`demo_mode.persona_generated`) every time a persona is generated, which can be
690
+ useful for tracking usage over time and alerting to any unexpected spikes,
691
+ drops in usage, and changes to performance. The custom event payload includes the
692
+ persona name and variant, in addition to standard attributes like execution
693
+ duration and exception details (if an error occurred during generation).
694
+
655
695
  Again, to learn more about how we use and operate our "demo"
656
696
  environments at **Betterment**, check out our ✨ [RailsConf 2022 talk entitled
657
697
  "RAILS_ENV=demo"](https://youtu.be/VibJu9IMohc)
@@ -669,7 +709,7 @@ creating a new issue to get early feedback on your proposed change.
669
709
 
670
710
  ### Suggested Workflow
671
711
 
672
- * Fork the project and create a new branch for your contribution.
673
- * Write your contribution (and any applicable test coverage).
674
- * Make sure all tests pass (`bundle exec rake`).
675
- * Submit a pull request.
712
+ - Fork the project and create a new branch for your contribution.
713
+ - Write your contribution (and any applicable test coverage).
714
+ - Make sure all tests pass (`bundle exec rake`).
715
+ - Submit a pull request.
@@ -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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CleverSequence
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)
38
+ if exists?(current)
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)
46
+ elsif current - lower > 1
47
+ _lower_bound(next_between(lower, current), lower, [current, upper].min)
48
+ else # current should == lower + 1
49
+ lower
50
+ end
51
+ end
52
+
53
+ def next_between(lower, upper)
54
+ [((lower + 1) / 2) + (upper / 2), lower * 2].min
55
+ end
56
+
57
+ def exists?(value)
58
+ klass.public_send(finder_method, block.call(value))
59
+ end
60
+
61
+ # TODO: Move onto modern finder methods.
62
+ def finder_method
63
+ :"find_by_#{column_name.to_s.underscore.sub('_crypt', '')}"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CleverSequence
4
+ module PostgresBackend
5
+ SEQUENCE_PREFIX = 'cs_'
6
+
7
+ class SequenceNotFoundError < StandardError
8
+ attr_reader :sequence_name, :klass, :attribute
9
+
10
+ def initialize(sequence_name:, klass:, attribute:)
11
+ @sequence_name = sequence_name
12
+ @klass = klass
13
+ @attribute = attribute
14
+
15
+ super(
16
+ "Sequence '#{sequence_name}' not found for #{klass.name}##{attribute}. "
17
+ )
18
+ end
19
+ end
20
+
21
+ module SequenceResult
22
+ Exists = Data.define(:sequence_name)
23
+ Missing = Data.define(:sequence_name, :klass, :attribute, :calculated_start_value)
24
+ end
25
+
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
+
48
+ def nextval(klass, attribute, block)
49
+ name = sequence_name(klass, attribute)
50
+ log "[DemoMode] nextval called for #{klass.name}##{attribute} (sequence: #{name})"
51
+
52
+ if sequence_exists?(name)
53
+ nextval_from_sequence(name, klass, attribute, block)
54
+ else
55
+ nextval_without_sequence(name, klass, attribute, block)
56
+ end
57
+ end
58
+
59
+ def sequence_name(klass, attribute)
60
+ table = klass.table_name.gsub(/[^a-z0-9_]/i, '_')
61
+ attr = attribute.to_s.gsub(/[^a-z0-9_]/i, '_')
62
+ # Handle PostgreSQL identifier limit:
63
+ limit = (63 - SEQUENCE_PREFIX.length) / 2
64
+ # Lowercase to avoid PostgreSQL case-sensitivity issues with unquoted identifiers
65
+ "#{SEQUENCE_PREFIX}#{table[0, limit]}_#{attr[0, limit]}".downcase
66
+ end
67
+
68
+ def sequence_cache
69
+ Thread.current[:clever_sequence_cache] ||= {}
70
+ end
71
+
72
+ private
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
+
129
+ def sequence_exists?(sequence_name)
130
+ if sequence_cache.key?(sequence_name)
131
+ exists = sequence_cache[sequence_name].is_a?(SequenceResult::Exists)
132
+ log "[DemoMode] Sequence #{sequence_name} #{exists ? 'exists' : 'missing'} (cached)"
133
+ return exists
134
+ end
135
+
136
+ exists = ActiveRecord::Base.connection.execute(
137
+ "SELECT 1 FROM information_schema.sequences " \
138
+ "WHERE sequence_name = '#{sequence_name}' LIMIT 1",
139
+ ).any?
140
+ log "[DemoMode] Sequence #{sequence_name} #{exists ? 'found' : 'not found'}"
141
+ exists
142
+ end
143
+
144
+ def calculate_sequence_value(klass, attribute, block, hint: nil)
145
+ column_name = klass.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
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
169
+
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}"
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -1,13 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'clever_sequence/lower_bound_finder'
4
+ require_relative 'clever_sequence/in_memory_backend'
5
+ require_relative 'clever_sequence/postgres_backend'
6
+
3
7
  class CleverSequence
4
8
  DEFAULT_BLOCK = ->(i) { i }
5
9
 
10
+ cattr_accessor(:sequences) { {} }
11
+ cattr_accessor(:use_database_sequences) { false }
12
+ cattr_accessor(:enforce_sequences_exist) { false }
13
+ cattr_accessor(:retry_on_uniqueness_violation) { true }
14
+
6
15
  class << self
16
+ alias use_database_sequences? use_database_sequences
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
23
+
7
24
  def reset!
25
+ backend.reset!
8
26
  sequences.each_value(&:reset!)
9
27
  end
10
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
+
11
39
  def next(klass, name)
12
40
  lookup(klass, name)&.next
13
41
  end
@@ -23,7 +51,6 @@ class CleverSequence
23
51
  end
24
52
  end
25
53
 
26
- cattr_accessor(:sequences) { {} }
27
54
  attr_reader :klass, :attribute, :block
28
55
 
29
56
  def initialize(attribute, &block)
@@ -38,64 +65,48 @@ class CleverSequence
38
65
  end
39
66
 
40
67
  def next
41
- @last_value = last_value + 1
68
+ value = if klass
69
+ self.class.backend.nextval(klass, attribute, block)
70
+ else
71
+ (last_value || 0) + 1
72
+ end
73
+ self.last_value = value
42
74
  last
43
75
  end
44
76
 
45
77
  def last
46
- block.call(last_value)
78
+ block.call(last_value || (klass ? self.class.backend.starting_value(klass, attribute, block) : 0))
47
79
  end
48
80
 
49
81
  def reset!
50
- remove_instance_variable(:@last_value) if instance_variable_defined?(:@last_value)
82
+ clear_last_value
51
83
  end
52
84
 
53
85
  private
54
86
 
55
87
  def last_value
56
- @last_value ||= starting_value
57
- end
58
-
59
- def starting_value
60
- if column_exists?
61
- LowerBoundFinder.new(klass, column_name, block).lower_bound
88
+ if klass
89
+ Thread.current[:clever_sequence_last_value]&.dig(klass.name, attribute)
62
90
  else
63
- 0
91
+ @last_value
64
92
  end
65
93
  end
66
94
 
67
- def column_name
68
- klass.attribute_aliases[attribute] || attribute
69
- end
70
-
71
- def column_exists?
72
- klass && klass.column_names.include?(column_name)
73
- end
74
-
75
- LowerBoundFinder = Struct.new(:klass, :column_name, :block) do
76
- def lower_bound(current = 1, lower = 0, upper = Float::INFINITY)
77
- if exists?(current)
78
- lower_bound(next_between(current, upper), [current, lower].max, upper)
79
- elsif current - lower > 1
80
- lower_bound(next_between(lower, current), lower, [current, upper].min)
81
- else # current should == lower + 1
82
- lower
83
- end
84
- end
85
-
86
- private
87
-
88
- def next_between(lower, upper)
89
- [((lower + 1) / 2) + (upper / 2), lower * 2].min
90
- end
91
-
92
- def exists?(value)
93
- klass.public_send(finder_method, block.call(value))
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
94
102
  end
103
+ end
95
104
 
96
- # TODO: Move onto modern finder methods.
97
- def finder_method
98
- :"find_by_#{column_name.to_s.underscore.sub('_crypt', '')}"
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
99
110
  end
100
111
  end
101
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,12 +63,30 @@ module DemoMode
63
63
  end
64
64
 
65
65
  def generate!(variant: :default, password: nil, options: {})
66
- variant = variants[variant]
67
- CleverSequence.reset! if defined?(CleverSequence)
68
- DemoMode.current_password = password if password
69
- DemoMode.around_persona_generation.call(variant.signinable_generator, **options)
70
- ensure
71
- DemoMode.current_password = nil
66
+ retried = false
67
+ ActiveSupport::Notifications.instrument('demo_mode.persona.generate', name: name, variant: variant) do
68
+ variant = variants[variant]
69
+ CleverSequence.reset! if defined?(CleverSequence)
70
+ DemoMode.current_password = password if password
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
79
+ ensure
80
+ DemoMode.current_password = nil
81
+ end
82
+ end
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
72
90
  end
73
91
 
74
92
  def callout(callout = true) # rubocop:disable Style/OptionalBooleanParameter
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DemoMode
4
- VERSION = '3.4.0'
4
+ VERSION = '3.6.0'
5
5
  end
@@ -34,6 +34,14 @@ DemoMode.configure do
34
34
  # ====================================
35
35
  # personas_path 'config/personas'
36
36
 
37
+ # CleverSequence configuration (for FactoryBot sequence extension):
38
+ # ==================================================================
39
+ # Enable PostgreSQL-backed sequences (defaults to false):
40
+ # CleverSequence.use_database_sequences = true
41
+ #
42
+ # Raise an error if a database sequence doesn't exist (defaults to false):
43
+ # CleverSequence.enforce_sequences_exist = true
44
+
37
45
  # A callback that wraps persona-based account generation.
38
46
  # You must run `generator.call` and return the "signinable" object:
39
47
  # ==================================================
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.4.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
@@ -227,6 +228,20 @@ dependencies:
227
228
  - - ">="
228
229
  - !ruby/object:Gem::Version
229
230
  version: '0'
231
+ - !ruby/object:Gem::Dependency
232
+ name: pg
233
+ requirement: !ruby/object:Gem::Requirement
234
+ requirements:
235
+ - - ">="
236
+ - !ruby/object:Gem::Version
237
+ version: '0'
238
+ type: :development
239
+ prerelease: false
240
+ version_requirements: !ruby/object:Gem::Requirement
241
+ requirements:
242
+ - - ">="
243
+ - !ruby/object:Gem::Version
244
+ version: '0'
230
245
  - !ruby/object:Gem::Dependency
231
246
  name: rspec-rails
232
247
  requirement: !ruby/object:Gem::Requirement
@@ -324,6 +339,9 @@ files:
324
339
  - db/migrate/20250210222933_add_demo_mode_sessions_status.rb
325
340
  - lib/demo_mode.rb
326
341
  - lib/demo_mode/clever_sequence.rb
342
+ - lib/demo_mode/clever_sequence/in_memory_backend.rb
343
+ - lib/demo_mode/clever_sequence/lower_bound_finder.rb
344
+ - lib/demo_mode/clever_sequence/postgres_backend.rb
327
345
  - lib/demo_mode/cli.rb
328
346
  - lib/demo_mode/clis/multi_word_search_patch.rb
329
347
  - lib/demo_mode/concerns/configurable.rb
@@ -350,6 +368,7 @@ licenses:
350
368
  metadata:
351
369
  rubygems_mfa_required: 'true'
352
370
  changelog_uri: https://github.com/Betterment/demo_mode/blob/main/CHANGELOG.md
371
+ post_install_message:
353
372
  rdoc_options: []
354
373
  require_paths:
355
374
  - lib
@@ -364,7 +383,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
364
383
  - !ruby/object:Gem::Version
365
384
  version: '0'
366
385
  requirements: []
367
- rubygems_version: 3.6.8
386
+ rubygems_version: 3.4.10
387
+ signing_key:
368
388
  specification_version: 4
369
389
  summary: A configurable demo mode for your Rails app.
370
390
  test_files: []