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 +4 -4
- data/README.md +62 -22
- data/lib/demo_mode/clever_sequence/in_memory_backend.rb +51 -0
- data/lib/demo_mode/clever_sequence/lower_bound_finder.rb +66 -0
- data/lib/demo_mode/clever_sequence/postgres_backend.rb +184 -0
- data/lib/demo_mode/clever_sequence.rb +52 -41
- data/lib/demo_mode/config.rb +1 -0
- data/lib/demo_mode/persona.rb +24 -6
- data/lib/demo_mode/version.rb +1 -1
- data/lib/generators/templates/initializer.rb +8 -0
- metadata +23 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b2e916a3b2b58e6f3cbd027fad72a91b8cb550443a50e64988caa0dbd1ff6fa
|
|
4
|
+
data.tar.gz: ff00e7655ecf6d9e5ae118b84542bf97ec6e568143e4c2961ef42b1c28e0894c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://rubygems.org/gems/demo_mode)
|
|
4
4
|
[](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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
clear_last_value
|
|
51
83
|
end
|
|
52
84
|
|
|
53
85
|
private
|
|
54
86
|
|
|
55
87
|
def last_value
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
91
|
+
@last_value
|
|
64
92
|
end
|
|
65
93
|
end
|
|
66
94
|
|
|
67
|
-
def
|
|
68
|
-
klass
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
:
|
|
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
|
data/lib/demo_mode/config.rb
CHANGED
|
@@ -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
|
data/lib/demo_mode/persona.rb
CHANGED
|
@@ -63,12 +63,30 @@ module DemoMode
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def generate!(variant: :default, password: nil, options: {})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
data/lib/demo_mode/version.rb
CHANGED
|
@@ -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
|
+
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:
|
|
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.
|
|
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: []
|