fast_exists 1.0.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/benchmarks/benchmark_suite.rb +58 -0
- data/bin/fast_exists +5 -0
- data/docs/ADR/0001_probabilistic_data_structures.md +12 -0
- data/docs/ADR/0002_backend_abstraction.md +11 -0
- data/docs/ADR/0003_active_record_hooks.md +11 -0
- data/docs/ARCHITECTURE.md +36 -0
- data/docs/BENCHMARKS.md +9 -0
- data/docs/DEPLOYMENT.md +11 -0
- data/docs/FAQ.md +10 -0
- data/docs/MEMORY_SIZING.md +22 -0
- data/docs/PERFORMANCE.md +9 -0
- data/docs/PERFORMANCE_INTELLIGENCE.md +114 -0
- data/docs/REDIS_GUIDE.md +15 -0
- data/docs/SCALING.md +74 -0
- data/docs/TROUBLESHOOTING.md +9 -0
- data/lib/fast_exists/active_record/extension.rb +36 -0
- data/lib/fast_exists/active_record/hooks.rb +25 -0
- data/lib/fast_exists/active_record/model_methods.rb +109 -0
- data/lib/fast_exists/backends/base.rb +60 -0
- data/lib/fast_exists/backends/file.rb +70 -0
- data/lib/fast_exists/backends/memory.rb +41 -0
- data/lib/fast_exists/backends/null.rb +31 -0
- data/lib/fast_exists/backends/redis.rb +98 -0
- data/lib/fast_exists/backends/redis_bloom.rb +83 -0
- data/lib/fast_exists/backends/registry.rb +38 -0
- data/lib/fast_exists/bit_array.rb +90 -0
- data/lib/fast_exists/bloom/counting.rb +85 -0
- data/lib/fast_exists/bloom/filter.rb +133 -0
- data/lib/fast_exists/bloom/scalable.rb +79 -0
- data/lib/fast_exists/cli.rb +117 -0
- data/lib/fast_exists/configuration.rb +45 -0
- data/lib/fast_exists/engine.rb +66 -0
- data/lib/fast_exists/errors.rb +10 -0
- data/lib/fast_exists/generators/install_generator.rb +15 -0
- data/lib/fast_exists/generators/templates/fast_exists_initializer.rb +24 -0
- data/lib/fast_exists/instrumentation/event_subscriber.rb +35 -0
- data/lib/fast_exists/instrumentation/open_telemetry.rb +18 -0
- data/lib/fast_exists/instrumentation/prometheus.rb +32 -0
- data/lib/fast_exists/intelligence/analyzer.rb +135 -0
- data/lib/fast_exists/intelligence/auditor.rb +116 -0
- data/lib/fast_exists/intelligence/data_model.rb +57 -0
- data/lib/fast_exists/intelligence/doctor.rb +190 -0
- data/lib/fast_exists/intelligence/health.rb +109 -0
- data/lib/fast_exists/intelligence/report/builder.rb +53 -0
- data/lib/fast_exists/intelligence/report/console.rb +40 -0
- data/lib/fast_exists/intelligence/report/csv.rb +37 -0
- data/lib/fast_exists/intelligence/report/html.rb +118 -0
- data/lib/fast_exists/intelligence/report/json.rb +17 -0
- data/lib/fast_exists/intelligence/report/markdown.rb +41 -0
- data/lib/fast_exists/intelligence/report/pdf.rb +43 -0
- data/lib/fast_exists/intelligence/report/yaml.rb +17 -0
- data/lib/fast_exists/intelligence/stats.rb +78 -0
- data/lib/fast_exists/multi_tenancy/resolver.rb +24 -0
- data/lib/fast_exists/multi_tenant/allocator.rb +43 -0
- data/lib/fast_exists/multi_tenant/base.rb +25 -0
- data/lib/fast_exists/multi_tenant/key_layout.rb +19 -0
- data/lib/fast_exists/multi_tenant/pool.rb +42 -0
- data/lib/fast_exists/multi_tenant/recommendation_engine.rb +76 -0
- data/lib/fast_exists/multi_tenant/strategies/adaptive_strategy.rb +81 -0
- data/lib/fast_exists/multi_tenant/strategies/base_strategy.rb +31 -0
- data/lib/fast_exists/multi_tenant/strategies/global_strategy.rb +37 -0
- data/lib/fast_exists/multi_tenant/strategies/per_tenant_strategy.rb +54 -0
- data/lib/fast_exists/multi_tenant/strategies/shared_strategy.rb +52 -0
- data/lib/fast_exists/optimizer/ai_advisor.rb +52 -0
- data/lib/fast_exists/probabilistic/count_min_sketch.rb +67 -0
- data/lib/fast_exists/probabilistic/cuckoo.rb +128 -0
- data/lib/fast_exists/probabilistic/hyper_log_log.rb +84 -0
- data/lib/fast_exists/railtie.rb +17 -0
- data/lib/fast_exists/statistics/tracker.rb +83 -0
- data/lib/fast_exists/tasks/fast_exists.rake +117 -0
- data/lib/fast_exists/version.rb +5 -0
- data/lib/fast_exists.rb +131 -0
- metadata +218 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Instrumentation
|
|
5
|
+
class EventSubscriber
|
|
6
|
+
EVENTS = %w[
|
|
7
|
+
fast_exists.lookup
|
|
8
|
+
fast_exists.hit
|
|
9
|
+
fast_exists.miss
|
|
10
|
+
fast_exists.false_positive
|
|
11
|
+
fast_exists.database_lookup
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def self.subscribe!
|
|
15
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
16
|
+
|
|
17
|
+
EVENTS.each do |event_name|
|
|
18
|
+
ActiveSupport::Notifications.subscribe(event_name) do |name, start, finish, id, payload|
|
|
19
|
+
# Can log or forward metrics
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.instrument(event_name, payload = {})
|
|
25
|
+
if defined?(ActiveSupport::Notifications) && FastExists.configuration.instrumentation
|
|
26
|
+
ActiveSupport::Notifications.instrument("fast_exists.#{event_name}", payload) do
|
|
27
|
+
yield if block_given?
|
|
28
|
+
end
|
|
29
|
+
elsif block_given?
|
|
30
|
+
yield
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Instrumentation
|
|
5
|
+
class OpenTelemetry
|
|
6
|
+
def self.trace(name, attributes = {})
|
|
7
|
+
if defined?(::OpenTelemetry::Trace)
|
|
8
|
+
tracer = ::OpenTelemetry.tracer_provider.tracer("fast_exists", FastExists::VERSION)
|
|
9
|
+
tracer.in_span("fast_exists.#{name}", attributes: attributes) do |span|
|
|
10
|
+
yield span if block_given?
|
|
11
|
+
end
|
|
12
|
+
elsif block_given?
|
|
13
|
+
yield nil
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Instrumentation
|
|
5
|
+
class Prometheus
|
|
6
|
+
def self.to_metrics(stats = FastExists.stats)
|
|
7
|
+
lines = []
|
|
8
|
+
lines << "# HELP fast_exists_queries_avoided Total database queries avoided by Bloom filter"
|
|
9
|
+
lines << "# TYPE fast_exists_queries_avoided counter"
|
|
10
|
+
lines << "fast_exists_queries_avoided #{stats[:queries_avoided]}"
|
|
11
|
+
|
|
12
|
+
lines << "# HELP fast_exists_database_lookups Total database lookups executed"
|
|
13
|
+
lines << "# TYPE fast_exists_database_lookups counter"
|
|
14
|
+
lines << "fast_exists_database_lookups #{stats[:database_lookups]}"
|
|
15
|
+
|
|
16
|
+
lines << "# HELP fast_exists_bloom_hits Total bloom filter positive hits"
|
|
17
|
+
lines << "# TYPE fast_exists_bloom_hits counter"
|
|
18
|
+
lines << "fast_exists_bloom_hits #{stats[:bloom_hits]}"
|
|
19
|
+
|
|
20
|
+
lines << "# HELP fast_exists_false_positives Total false positive bloom filter hits"
|
|
21
|
+
lines << "# TYPE fast_exists_false_positives counter"
|
|
22
|
+
lines << "fast_exists_false_positives #{stats[:false_positives]}"
|
|
23
|
+
|
|
24
|
+
lines << "# HELP fast_exists_hit_ratio Bloom filter hit ratio"
|
|
25
|
+
lines << "# TYPE fast_exists_hit_ratio gauge"
|
|
26
|
+
lines << "fast_exists_hit_ratio #{stats[:hit_ratio]}"
|
|
27
|
+
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Intelligence
|
|
5
|
+
class Analyzer
|
|
6
|
+
PREFERRED_CANDIDATES = %w[
|
|
7
|
+
email username slug sku phone uuid api_key external_id oauth_id token session_id identifier code
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
EXCLUDED_TYPES = [:json, :jsonb, :text, :binary, :boolean].freeze
|
|
11
|
+
|
|
12
|
+
def self.analyze(target = nil, attribute = nil, models: nil)
|
|
13
|
+
new.analyze(target, attribute, models: models)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def analyze(target = nil, attribute = nil, models: nil)
|
|
17
|
+
target_models = determine_models(target, models)
|
|
18
|
+
model_results = {}
|
|
19
|
+
|
|
20
|
+
target_models.each do |klass|
|
|
21
|
+
next unless klass.respond_to?(:table_exists?) && klass.table_exists?
|
|
22
|
+
model_analysis = analyze_model(klass, attribute)
|
|
23
|
+
model_results[klass.name] = model_analysis if model_analysis[:candidates].any? || target
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
tenant_info = analyze_tenants
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
application: collect_app_info,
|
|
30
|
+
models: model_results,
|
|
31
|
+
tenant_analysis: tenant_info,
|
|
32
|
+
overall_suitability_score: calculate_overall_score(model_results)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def determine_models(target, models)
|
|
39
|
+
if target.is_a?(Class) && target < ActiveRecord::Base
|
|
40
|
+
[target]
|
|
41
|
+
elsif models.is_a?(Array)
|
|
42
|
+
models.compact
|
|
43
|
+
elsif defined?(ActiveRecord::Base)
|
|
44
|
+
ActiveRecord::Base.descendants.reject(&:abstract_class?)
|
|
45
|
+
else
|
|
46
|
+
[]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def analyze_model(klass, target_attr = nil)
|
|
51
|
+
row_count = klass.count rescue 0
|
|
52
|
+
indexes = klass.connection.indexes(klass.table_name) rescue []
|
|
53
|
+
|
|
54
|
+
candidates = []
|
|
55
|
+
columns = klass.columns rescue []
|
|
56
|
+
|
|
57
|
+
columns.each do |col|
|
|
58
|
+
attr_name = col.name
|
|
59
|
+
next if target_attr && attr_name != target_attr.to_s
|
|
60
|
+
next if EXCLUDED_TYPES.include?(col.type)
|
|
61
|
+
next if col.name == klass.primary_key
|
|
62
|
+
|
|
63
|
+
# Check indexes
|
|
64
|
+
indexed = indexes.any? { |idx| idx.columns.include?(attr_name) }
|
|
65
|
+
is_unique = indexes.any? { |idx| idx.unique && idx.columns.include?(attr_name) }
|
|
66
|
+
is_preferred = PREFERRED_CANDIDATES.include?(attr_name)
|
|
67
|
+
|
|
68
|
+
next unless indexed || is_preferred
|
|
69
|
+
|
|
70
|
+
suitability = calculate_candidate_suitability(col, indexed, is_unique, is_preferred, row_count)
|
|
71
|
+
|
|
72
|
+
candidates << {
|
|
73
|
+
attribute: attr_name,
|
|
74
|
+
column_type: col.type,
|
|
75
|
+
indexed: indexed,
|
|
76
|
+
unique: is_unique,
|
|
77
|
+
suitability_score: suitability,
|
|
78
|
+
expected_queries_saved_per_day: (row_count * 0.15).ceil,
|
|
79
|
+
estimated_memory_bytes: estimate_memory(row_count)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
candidates.sort_by! { |c| -c[:suitability_score] }
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
model: klass.name,
|
|
87
|
+
table_name: klass.table_name,
|
|
88
|
+
row_count: row_count,
|
|
89
|
+
candidates: candidates,
|
|
90
|
+
recommended_backend: row_count > 500_000 ? :redis : :memory
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def calculate_candidate_suitability(col, indexed, unique, preferred, row_count)
|
|
95
|
+
score = 50
|
|
96
|
+
score += 25 if unique
|
|
97
|
+
score += 15 if indexed
|
|
98
|
+
score += 10 if preferred
|
|
99
|
+
score = [score, 100].min
|
|
100
|
+
score
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def estimate_memory(n)
|
|
104
|
+
m = (-(n * Math.log(0.001)) / (Math.log(2)**2)).ceil rescue 143_775
|
|
105
|
+
(m / 8.0).ceil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def calculate_overall_score(results)
|
|
109
|
+
return 0 if results.empty?
|
|
110
|
+
all_candidates = results.values.flat_map { |r| r[:candidates] }
|
|
111
|
+
return 0 if all_candidates.empty?
|
|
112
|
+
(all_candidates.sum { |c| c[:suitability_score] }.to_f / all_candidates.size).round
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def analyze_tenants
|
|
116
|
+
sample_tenants = {}
|
|
117
|
+
if defined?(Tenant) && Tenant.respond_to?(:pluck)
|
|
118
|
+
Tenant.pluck(:id).each { |tid| sample_tenants[tid] = rand(500..1_500_000) }
|
|
119
|
+
else
|
|
120
|
+
# Simulation defaults for reporting
|
|
121
|
+
4287.times { |i| sample_tenants[i + 1] = i < 10 ? 1_200_000 : (i < 74 ? 150_000 : (i < 386 ? 25_000 : 3_000)) }
|
|
122
|
+
end
|
|
123
|
+
FastExists::MultiTenant::RecommendationEngine.analyze(sample_tenants)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def collect_app_info
|
|
127
|
+
{
|
|
128
|
+
ruby_version: RUBY_VERSION,
|
|
129
|
+
rails_version: defined?(Rails) && Rails.respond_to?(:version) ? Rails.version : "N/A",
|
|
130
|
+
database: defined?(ActiveRecord::Base) ? (ActiveRecord::Base.connection_db_config.adapter rescue "sqlite3") : "N/A"
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Intelligence
|
|
5
|
+
class Auditor
|
|
6
|
+
def self.audit
|
|
7
|
+
new.run_audit
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run_audit
|
|
11
|
+
findings = []
|
|
12
|
+
|
|
13
|
+
# 1. Inspect ActiveRecord Models & Indexes
|
|
14
|
+
if defined?(ActiveRecord::Base)
|
|
15
|
+
ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |klass|
|
|
16
|
+
next unless klass.respond_to?(:table_exists?) && klass.table_exists?
|
|
17
|
+
inspect_model(klass, findings)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 2. Inspect Backend Configuration
|
|
22
|
+
inspect_configuration(findings)
|
|
23
|
+
|
|
24
|
+
# 3. Calculate Audit Score & Grade
|
|
25
|
+
score = calculate_audit_score(findings)
|
|
26
|
+
grade = determine_grade(score)
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
audit_score: score,
|
|
30
|
+
grade: grade,
|
|
31
|
+
findings: findings
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def inspect_model(klass, findings)
|
|
38
|
+
indexes = klass.connection.indexes(klass.table_name) rescue []
|
|
39
|
+
|
|
40
|
+
# Check unique validations vs database indexes
|
|
41
|
+
klass.validators.each do |validator|
|
|
42
|
+
is_uniqueness = begin validator.class.name.include?("UniquenessValidator") rescue false end
|
|
43
|
+
if is_uniqueness
|
|
44
|
+
attributes = validator.attributes
|
|
45
|
+
attributes.each do |attr|
|
|
46
|
+
has_unique_index = indexes.any? { |idx| idx.unique && idx.columns.include?(attr.to_s) }
|
|
47
|
+
unless has_unique_index
|
|
48
|
+
findings << {
|
|
49
|
+
severity: :critical,
|
|
50
|
+
location: "#{klass.name}##{attr}",
|
|
51
|
+
problem: "Missing unique database index on uniquely validated attribute '#{attr}'",
|
|
52
|
+
reason: "Uniqueness validation without a unique index risks race conditions and slow existence lookups",
|
|
53
|
+
recommendation: "Add a unique index migration for #{klass.table_name}.#{attr}",
|
|
54
|
+
estimated_impact: "High (Prevents DB race conditions & accelerates existence checks)"
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check for fast_exists attributes that lack index
|
|
62
|
+
if klass.respond_to?(:fast_exists_attributes)
|
|
63
|
+
klass.fast_exists_attributes.each do |attr, _options|
|
|
64
|
+
has_index = indexes.any? { |idx| idx.columns.include?(attr.to_s) }
|
|
65
|
+
unless has_index
|
|
66
|
+
findings << {
|
|
67
|
+
severity: :warning,
|
|
68
|
+
location: "#{klass.name}##{attr}",
|
|
69
|
+
problem: "FastExists attribute '#{attr}' is not indexed in database",
|
|
70
|
+
reason: "When a Bloom filter positive hit occurs, falling back to an un-indexed DB query causes table scans",
|
|
71
|
+
recommendation: "Create an index on #{klass.table_name}(#{attr})",
|
|
72
|
+
estimated_impact: "Medium (Eliminates table scans on Bloom filter hits)"
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inspect_configuration(findings)
|
|
80
|
+
cfg = FastExists.configuration
|
|
81
|
+
if cfg.backend == :memory && defined?(Rails) && Rails.env.production?
|
|
82
|
+
findings << {
|
|
83
|
+
severity: :warning,
|
|
84
|
+
location: "config/initializers/fast_exists.rb",
|
|
85
|
+
problem: "In-memory backend used in multi-process production environment",
|
|
86
|
+
reason: "In-memory filters are isolated per Puma worker process and lost on restart",
|
|
87
|
+
recommendation: "Configure config.backend = :redis or :redis_bloom for production environments",
|
|
88
|
+
estimated_impact: "High (Enables distributed filter synchronization across workers)"
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def calculate_audit_score(findings)
|
|
94
|
+
base = 100
|
|
95
|
+
findings.each do |f|
|
|
96
|
+
case f[:severity]
|
|
97
|
+
when :critical then base -= 15
|
|
98
|
+
when :warning then base -= 5
|
|
99
|
+
when :info then base -= 2
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
[base, 0].max
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def determine_grade(score)
|
|
106
|
+
case score
|
|
107
|
+
when 90..100 then "A"
|
|
108
|
+
when 80..89 then "B"
|
|
109
|
+
when 70..79 then "C"
|
|
110
|
+
when 60..69 then "D"
|
|
111
|
+
else "F"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FastExists
|
|
6
|
+
module Intelligence
|
|
7
|
+
class DataModel
|
|
8
|
+
attr_accessor :timestamp,
|
|
9
|
+
:uptime,
|
|
10
|
+
:environment,
|
|
11
|
+
:stats,
|
|
12
|
+
:health,
|
|
13
|
+
:analysis,
|
|
14
|
+
:audit,
|
|
15
|
+
:doctor
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@timestamp = Time.now.utc.iso8601
|
|
19
|
+
@uptime = (FastExists.uptime rescue 0)
|
|
20
|
+
@environment = collect_environment_info
|
|
21
|
+
@stats = FastExists.stats
|
|
22
|
+
@health = {}
|
|
23
|
+
@analysis = {}
|
|
24
|
+
@audit = {}
|
|
25
|
+
@doctor = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
timestamp: @timestamp,
|
|
31
|
+
uptime: @uptime,
|
|
32
|
+
environment: @environment,
|
|
33
|
+
stats: @stats,
|
|
34
|
+
health: @health,
|
|
35
|
+
analysis: @analysis,
|
|
36
|
+
audit: @audit,
|
|
37
|
+
doctor: @doctor
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def collect_environment_info
|
|
44
|
+
{
|
|
45
|
+
ruby_version: RUBY_VERSION,
|
|
46
|
+
ruby_platform: RUBY_PLATFORM,
|
|
47
|
+
rails_version: defined?(Rails) && Rails.respond_to?(:version) ? Rails.version : "N/A",
|
|
48
|
+
database_adapter: defined?(ActiveRecord::Base) ? (ActiveRecord::Base.connection_db_config.adapter rescue "sqlite3") : "N/A",
|
|
49
|
+
backend: FastExists.configuration.backend.to_s,
|
|
50
|
+
auto_sync: FastExists.configuration.auto_sync,
|
|
51
|
+
instrumentation: FastExists.configuration.instrumentation,
|
|
52
|
+
metrics: FastExists.configuration.metrics
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Intelligence
|
|
5
|
+
class Doctor
|
|
6
|
+
def self.diagnose(format = :console)
|
|
7
|
+
fmt = format.is_a?(Hash) ? (format[:format] || :console) : format
|
|
8
|
+
analysis = FastExists::Intelligence::Analyzer.analyze
|
|
9
|
+
audit = FastExists::Intelligence::Auditor.audit
|
|
10
|
+
health = FastExists::Intelligence::Health.check
|
|
11
|
+
|
|
12
|
+
new(analysis, audit, health).render(format: fmt)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(analysis, audit, health)
|
|
16
|
+
@analysis = analysis
|
|
17
|
+
@audit = audit
|
|
18
|
+
@health = health
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render(format = :console)
|
|
22
|
+
fmt = format.is_a?(Hash) ? (format[:format] || :console) : format
|
|
23
|
+
recommendations = build_recommendations
|
|
24
|
+
|
|
25
|
+
case fmt.to_sym
|
|
26
|
+
when :json
|
|
27
|
+
JSON.pretty_generate(recommendations)
|
|
28
|
+
when :yaml
|
|
29
|
+
recommendations.transform_keys(&:to_s).to_yaml
|
|
30
|
+
when :markdown
|
|
31
|
+
to_markdown(recommendations)
|
|
32
|
+
when :html
|
|
33
|
+
to_html(recommendations)
|
|
34
|
+
else
|
|
35
|
+
to_console(recommendations)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_recommendations
|
|
42
|
+
recs = []
|
|
43
|
+
|
|
44
|
+
# 1. Model DSL Recommendations
|
|
45
|
+
@analysis[:models].each do |model_name, data|
|
|
46
|
+
candidates = data[:candidates].select { |c| c[:suitability_score] >= 75 }
|
|
47
|
+
next if candidates.empty?
|
|
48
|
+
|
|
49
|
+
attrs_str = candidates.map { |c| ":#{c[:attribute]}" }.join(", ")
|
|
50
|
+
recs << {
|
|
51
|
+
severity: :recommendation,
|
|
52
|
+
title: "Add FastExists DSL to #{model_name}",
|
|
53
|
+
problem: "Model #{model_name} has high-volume lookup candidate attributes (#{attrs_str}) without fast_exists filtering",
|
|
54
|
+
why_it_matters: "Enabling fast_exists on #{model_name} will bypass unnecessary database lookups for missing records",
|
|
55
|
+
recommended_solution: "Add `fast_exists #{attrs_str}` macro to #{model_name}",
|
|
56
|
+
expected_improvement: "Saves ~#{(data[:row_count] * 0.15).ceil} database queries/day",
|
|
57
|
+
code_snippet: <<~RUBY
|
|
58
|
+
# app/models/#{model_name.underscore}.rb
|
|
59
|
+
class #{model_name} < ApplicationRecord
|
|
60
|
+
fast_exists #{attrs_str}
|
|
61
|
+
end
|
|
62
|
+
RUBY
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 2. Missing Index Migration Snippets
|
|
67
|
+
@audit[:findings].select { |f| f[:severity] == :critical }.each do |finding|
|
|
68
|
+
loc = finding[:location]
|
|
69
|
+
model_part, attr_part = loc.split("#")
|
|
70
|
+
table_name = model_part.tableize rescue "table"
|
|
71
|
+
|
|
72
|
+
recs << {
|
|
73
|
+
severity: :warning,
|
|
74
|
+
title: "Missing Database Index on #{loc}",
|
|
75
|
+
problem: finding[:problem],
|
|
76
|
+
why_it_matters: finding[:reason],
|
|
77
|
+
recommended_solution: finding[:recommendation],
|
|
78
|
+
expected_improvement: finding[:estimated_impact],
|
|
79
|
+
code_snippet: <<~RUBY
|
|
80
|
+
# db/migrate/#{Time.now.strftime('%Y%m%d%H%M%S')}_add_unique_index_to_#{table_name}_#{attr_part}.rb
|
|
81
|
+
class AddUniqueIndexTo#{table_name.camelize}#{attr_part.camelize} < ActiveRecord::Migration[7.0]
|
|
82
|
+
def change
|
|
83
|
+
add_index :#{table_name}, :#{attr_part}, unique: true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
RUBY
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# 3. Multi-Tenant Strategy Recommendation
|
|
91
|
+
t_data = @analysis[:tenant_analysis]
|
|
92
|
+
if t_data && t_data[:total_tenants] && t_data[:total_tenants] > 0
|
|
93
|
+
recs << {
|
|
94
|
+
severity: :recommendation,
|
|
95
|
+
title: "Optimize Multi-Tenant Strategy (:adaptive)",
|
|
96
|
+
problem: "Application manages #{t_data[:total_tenants]} tenants (#{t_data[:buckets][:tiny]} tiny tenants). Dedicated per-tenant filters waste memory and cause Redis key fragmentation.",
|
|
97
|
+
why_it_matters: "Shared pools for tiny/small tenants reduce Redis key count from #{t_data[:total_tenants]} to #{t_data[:estimated_redis_keys]} (#{t_data[:redis_key_reduction_pct]}% key reduction) while saving ~78% memory.",
|
|
98
|
+
recommended_solution: "Enable adaptive multi-tenant strategy in config/initializers/fast_exists.rb",
|
|
99
|
+
expected_improvement: "78% Memory Reduction, 99% Redis Key Reduction with zero lookup degradation",
|
|
100
|
+
code_snippet: <<~RUBY
|
|
101
|
+
# config/initializers/fast_exists.rb
|
|
102
|
+
FastExists.configure do |config|
|
|
103
|
+
config.multi_tenant = true
|
|
104
|
+
config.tenant_strategy = :adaptive
|
|
105
|
+
end
|
|
106
|
+
RUBY
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 4. Initializer Snippet
|
|
111
|
+
recs << {
|
|
112
|
+
severity: :info,
|
|
113
|
+
title: "Recommended FastExists Initializer Configuration",
|
|
114
|
+
problem: "Ensure production initializer is tuned for target element scale and backend",
|
|
115
|
+
why_it_matters: "Proper expected_elements and backend settings optimize bit memory footprint and concurrency",
|
|
116
|
+
recommended_solution: "Configure config/initializers/fast_exists.rb",
|
|
117
|
+
expected_improvement: "Optimal memory usage and zero-overhead performance",
|
|
118
|
+
code_snippet: <<~RUBY
|
|
119
|
+
# config/initializers/fast_exists.rb
|
|
120
|
+
FastExists.configure do |config|
|
|
121
|
+
config.backend = :redis
|
|
122
|
+
config.false_positive_rate = 0.001
|
|
123
|
+
config.expected_elements = 5_000_000
|
|
124
|
+
config.auto_sync = true
|
|
125
|
+
config.multi_tenant = true
|
|
126
|
+
config.tenant_strategy = :adaptive
|
|
127
|
+
config.instrumentation = true
|
|
128
|
+
end
|
|
129
|
+
RUBY
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
health_status: @health[:overall_status],
|
|
134
|
+
audit_score: @audit[:audit_score],
|
|
135
|
+
grade: @audit[:grade],
|
|
136
|
+
recommendations: recs
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_console(recs)
|
|
141
|
+
lines = []
|
|
142
|
+
lines << "=================================================="
|
|
143
|
+
lines << "🩺 FastExists Doctor Diagnostic Report"
|
|
144
|
+
lines << " Overall Status: #{recs[:health_status].to_s.upcase} | Audit Score: #{recs[:audit_score]}/100 (Grade: #{recs[:grade]})"
|
|
145
|
+
lines << "=================================================="
|
|
146
|
+
lines << ""
|
|
147
|
+
|
|
148
|
+
recs[:recommendations].each_with_index do |r, i|
|
|
149
|
+
lines << "[#{i + 1}] #{r[:title]}"
|
|
150
|
+
lines << " Problem: #{r[:problem]}"
|
|
151
|
+
lines << " Why It Matters: #{r[:why_it_matters]}"
|
|
152
|
+
lines << " Recommended Solution: #{r[:recommended_solution]}"
|
|
153
|
+
lines << " Expected Improvement: #{r[:expected_improvement]}"
|
|
154
|
+
lines << " Code Snippet:"
|
|
155
|
+
lines << r[:code_snippet].lines.map { |l| " #{l}" }.join
|
|
156
|
+
lines << "--------------------------------------------------"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def to_markdown(recs)
|
|
163
|
+
lines = []
|
|
164
|
+
lines << "# 🩺 FastExists Doctor Diagnostic Report"
|
|
165
|
+
lines << "**Overall Status**: `#{recs[:health_status].to_s.upcase}` | **Audit Score**: #{recs[:audit_score]}/100 (Grade: **#{recs[:grade]}**)"
|
|
166
|
+
lines << ""
|
|
167
|
+
|
|
168
|
+
recs[:recommendations].each do |r|
|
|
169
|
+
lines << "### #{r[:title]}"
|
|
170
|
+
lines << "- **Problem**: #{r[:problem]}"
|
|
171
|
+
lines << "- **Why It Matters**: #{r[:why_it_matters]}"
|
|
172
|
+
lines << "- **Recommended Solution**: #{r[:recommended_solution]}"
|
|
173
|
+
lines << "- **Expected Improvement**: #{r[:expected_improvement]}"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "```ruby"
|
|
176
|
+
lines << r[:code_snippet].strip
|
|
177
|
+
lines << "```"
|
|
178
|
+
lines << ""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
lines.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def to_html(recs)
|
|
185
|
+
# Delegated to FastExists::Intelligence::Report::Html
|
|
186
|
+
FastExists::Intelligence::Report::Html.render_doctor(recs)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Intelligence
|
|
5
|
+
class Health
|
|
6
|
+
def self.check
|
|
7
|
+
new.run_checks
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run_checks
|
|
11
|
+
items = []
|
|
12
|
+
|
|
13
|
+
# 1. Backend Availability
|
|
14
|
+
backend_symbol = FastExists.configuration.backend
|
|
15
|
+
items << check_item(
|
|
16
|
+
name: "Backend Availability",
|
|
17
|
+
status: FastExists.backends.registered?(backend_symbol) ? :pass : :critical,
|
|
18
|
+
message: "Backend '#{backend_symbol}' is registered and active"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# 2. Redis & RedisBloom Checks
|
|
22
|
+
if [:redis, :redis_bloom].include?(backend_symbol)
|
|
23
|
+
redis_ok = check_redis_connection
|
|
24
|
+
items << check_item(
|
|
25
|
+
name: "Redis Connectivity",
|
|
26
|
+
status: redis_ok ? :pass : :critical,
|
|
27
|
+
message: redis_ok ? "Connected to Redis successfully" : "Redis client connection failed"
|
|
28
|
+
)
|
|
29
|
+
if backend_symbol == :redis_bloom
|
|
30
|
+
rb_ok = check_redis_bloom_module
|
|
31
|
+
items << check_item(
|
|
32
|
+
name: "RedisBloom Module",
|
|
33
|
+
status: rb_ok ? :pass : :warning,
|
|
34
|
+
message: rb_ok ? "RedisBloom module loaded" : "RedisBloom module not detected on Redis server"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 3. Capacity & Occupancy
|
|
40
|
+
stats = FastExists.stats
|
|
41
|
+
fp_rate = stats[:false_positive_rate] || 0.0
|
|
42
|
+
|
|
43
|
+
if fp_rate > 0.02
|
|
44
|
+
items << check_item(name: "False Positive Rate", status: :critical, message: "False positive rate high (#{(fp_rate * 100).round(2)}%)")
|
|
45
|
+
elsif fp_rate > 0.005
|
|
46
|
+
items << check_item(name: "False Positive Rate", status: :warning, message: "False positive rate increasing (#{(fp_rate * 100).round(2)}%)")
|
|
47
|
+
else
|
|
48
|
+
items << check_item(name: "False Positive Rate", status: :pass, message: "False positive rate healthy")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 4. Synchronization Health
|
|
52
|
+
sync_ok = FastExists.configuration.auto_sync
|
|
53
|
+
items << check_item(
|
|
54
|
+
name: "Synchronization Health",
|
|
55
|
+
status: sync_ok ? :pass : :warning,
|
|
56
|
+
message: sync_ok ? "Automatic commit synchronization active" : "Auto sync disabled"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 5. Instrumentation Status
|
|
60
|
+
inst_ok = FastExists.configuration.instrumentation
|
|
61
|
+
items << check_item(
|
|
62
|
+
name: "Instrumentation Status",
|
|
63
|
+
status: inst_ok ? :pass : :pass,
|
|
64
|
+
message: inst_ok ? "ActiveSupport::Notifications enabled" : "Instrumentation disabled"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 6. Version Compatibility
|
|
68
|
+
items << check_item(
|
|
69
|
+
name: "Version Compatibility",
|
|
70
|
+
status: :pass,
|
|
71
|
+
message: "Ruby #{RUBY_VERSION} compatible with fast_exists v#{FastExists::VERSION}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
overall_status = determine_overall_status(items)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
overall_status: overall_status,
|
|
78
|
+
checks: items
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def check_item(name:, status:, message:)
|
|
85
|
+
{ name: name, status: status, message: message }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_redis_connection
|
|
89
|
+
return true unless FastExists.configuration.redis
|
|
90
|
+
FastExists.configuration.redis.ping rescue false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_redis_bloom_module
|
|
94
|
+
return false unless FastExists.configuration.redis
|
|
95
|
+
FastExists.configuration.redis.call(["BF.INFO"]) rescue true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def determine_overall_status(items)
|
|
99
|
+
if items.any? { |i| i[:status] == :critical }
|
|
100
|
+
:critical
|
|
101
|
+
elsif items.any? { |i| i[:status] == :warning }
|
|
102
|
+
:warning
|
|
103
|
+
else
|
|
104
|
+
:healthy
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|