demo_mode 3.4.0 → 3.5.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: a112b6f295027661f77ad3e708d373f6e24483ee562aa23307787c29f5cb45a9
4
+ data.tar.gz: 61c4948ea423a70295d48ed8b62a2d7de6359efa30560a8b40faa6831e2ada3a
5
5
  SHA512:
6
- metadata.gz: 59d84c5fec1c5971a5661cb19383b4a831e59378ddaa9958f17f0577453706f2fb58eee90d71b2fa1870b22287a519f59f910fa828e72600b75b1c98435a2e0f
7
- data.tar.gz: 857e22d08f59ed49830170b8b7aa5446c276dd13d44a86a1ae1e6fbeebf991cf53a15cc833059eaa8a243481d38a8644845b1476769a8475c21044ac2117cbca
6
+ metadata.gz: 85fc9433912c6d7b9272cb9e7c526b9fdb7f3326784d6b65ba0b42dd9b9b022f643b79c7f7f5a299af5ee9bddf6c3594414a010fb1dc2bf670642a25b4c363c2
7
+ data.tar.gz: e05fed5948d72ef9cdd31bf01be8a9589648cdee0da4c6e24c080e1f82fab3cf2c669b6eb47dab470e730b8adbce943cd85c76c3c160f3bc1ed6bc458aaffad3
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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CleverSequence
4
+ LowerBoundFinder = Struct.new(:klass, :column_name, :block) do
5
+ def lower_bound(current = 1, lower = 0, upper = Float::INFINITY)
6
+ if exists?(current)
7
+ lower_bound(next_between(current, upper), [current, lower].max, upper)
8
+ elsif current - lower > 1
9
+ lower_bound(next_between(lower, current), lower, [current, upper].min)
10
+ else # current should == lower + 1
11
+ lower
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def next_between(lower, upper)
18
+ [((lower + 1) / 2) + (upper / 2), lower * 2].min
19
+ end
20
+
21
+ def exists?(value)
22
+ klass.public_send(finder_method, block.call(value))
23
+ end
24
+
25
+ # TODO: Move onto modern finder methods.
26
+ def finder_method
27
+ :"find_by_#{column_name.to_s.underscore.sub('_crypt', '')}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,98 @@
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 nextval(klass, attribute, block)
28
+ name = sequence_name(klass, attribute)
29
+
30
+ 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
37
+ 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
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
+ "#{SEQUENCE_PREFIX}#{table[0, limit]}_#{attr[0, limit]}"
65
+ end
66
+
67
+ def sequence_cache
68
+ @sequence_cache ||= {}
69
+ end
70
+
71
+ private
72
+
73
+ def sequence_exists?(sequence_name)
74
+ 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
81
+ end
82
+
83
+ ActiveRecord::Base.connection.execute(
84
+ "SELECT 1 FROM information_schema.sequences WHERE sequence_name = '#{sequence_name}' LIMIT 1",
85
+ ).any?
86
+ end
87
+
88
+ def calculate_sequence_value(klass, attribute, block)
89
+ column_name = klass.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
90
+ return 0 unless klass.column_names.include?(column_name)
91
+
92
+ ActiveRecord::Base.with_transactional_lock("lower-bound-#{klass}-#{column_name}") do
93
+ LowerBoundFinder.new(klass, column_name, block).lower_bound
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'clever_sequence/lower_bound_finder'
4
+ require_relative 'clever_sequence/postgres_backend'
5
+
3
6
  class CleverSequence
4
7
  DEFAULT_BLOCK = ->(i) { i }
5
8
 
9
+ cattr_accessor(:sequences) { {} }
10
+ cattr_accessor(:use_database_sequences) { false }
11
+ cattr_accessor(:enforce_sequences_exist) { false }
12
+
6
13
  class << self
14
+ alias use_database_sequences? use_database_sequences
15
+ alias enforce_sequences_exist? enforce_sequences_exist
16
+
7
17
  def reset!
8
18
  sequences.each_value(&:reset!)
9
19
  end
@@ -23,7 +33,6 @@ class CleverSequence
23
33
  end
24
34
  end
25
35
 
26
- cattr_accessor(:sequences) { {} }
27
36
  attr_reader :klass, :attribute, :block
28
37
 
29
38
  def initialize(attribute, &block)
@@ -38,7 +47,11 @@ class CleverSequence
38
47
  end
39
48
 
40
49
  def next
41
- @last_value = last_value + 1
50
+ @last_value = if CleverSequence.use_database_sequences?
51
+ PostgresBackend.nextval(klass, attribute, block)
52
+ else
53
+ last_value + 1
54
+ end
42
55
  last
43
56
  end
44
57
 
@@ -71,31 +84,4 @@ class CleverSequence
71
84
  def column_exists?
72
85
  klass && klass.column_names.include?(column_name)
73
86
  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))
94
- end
95
-
96
- # TODO: Move onto modern finder methods.
97
- def finder_method
98
- :"find_by_#{column_name.to_s.underscore.sub('_crypt', '')}"
99
- end
100
- end
101
87
  end
@@ -63,12 +63,14 @@ 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
+ ActiveSupport::Notifications.instrument('demo_mode.persona.generate', name: name, variant: variant) do
67
+ variant = variants[variant]
68
+ CleverSequence.reset! if defined?(CleverSequence)
69
+ DemoMode.current_password = password if password
70
+ DemoMode.around_persona_generation.call(variant.signinable_generator, **options)
71
+ ensure
72
+ DemoMode.current_password = nil
73
+ end
72
74
  end
73
75
 
74
76
  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.5.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,7 +1,7 @@
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith
@@ -227,6 +227,20 @@ dependencies:
227
227
  - - ">="
228
228
  - !ruby/object:Gem::Version
229
229
  version: '0'
230
+ - !ruby/object:Gem::Dependency
231
+ name: pg
232
+ requirement: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
237
+ type: :development
238
+ prerelease: false
239
+ version_requirements: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - ">="
242
+ - !ruby/object:Gem::Version
243
+ version: '0'
230
244
  - !ruby/object:Gem::Dependency
231
245
  name: rspec-rails
232
246
  requirement: !ruby/object:Gem::Requirement
@@ -324,6 +338,8 @@ files:
324
338
  - db/migrate/20250210222933_add_demo_mode_sessions_status.rb
325
339
  - lib/demo_mode.rb
326
340
  - lib/demo_mode/clever_sequence.rb
341
+ - lib/demo_mode/clever_sequence/lower_bound_finder.rb
342
+ - lib/demo_mode/clever_sequence/postgres_backend.rb
327
343
  - lib/demo_mode/cli.rb
328
344
  - lib/demo_mode/clis/multi_word_search_patch.rb
329
345
  - lib/demo_mode/concerns/configurable.rb
@@ -364,7 +380,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
364
380
  - !ruby/object:Gem::Version
365
381
  version: '0'
366
382
  requirements: []
367
- rubygems_version: 3.6.8
383
+ rubygems_version: 4.0.6
368
384
  specification_version: 4
369
385
  summary: A configurable demo mode for your Rails app.
370
386
  test_files: []