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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module FastExists
|
|
6
|
+
class BitArray
|
|
7
|
+
attr_reader :size
|
|
8
|
+
|
|
9
|
+
def initialize(size)
|
|
10
|
+
raise InvalidArgumentError, "BitArray size must be positive" if size <= 0
|
|
11
|
+
|
|
12
|
+
@size = size
|
|
13
|
+
@bytesize = (size + 7) / 8
|
|
14
|
+
@bytes = ("\x00" * @bytesize).b
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(index)
|
|
19
|
+
validate_index!(index)
|
|
20
|
+
byte_idx = index / 8
|
|
21
|
+
bit_idx = index % 8
|
|
22
|
+
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
current_byte = @bytes.getbyte(byte_idx)
|
|
25
|
+
new_byte = current_byte | (1 << bit_idx)
|
|
26
|
+
@bytes.setbyte(byte_idx, new_byte)
|
|
27
|
+
end
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set?(index)
|
|
32
|
+
validate_index!(index)
|
|
33
|
+
byte_idx = index / 8
|
|
34
|
+
bit_idx = index % 8
|
|
35
|
+
|
|
36
|
+
byte = @bytes.getbyte(byte_idx)
|
|
37
|
+
(byte & (1 << bit_idx)) != 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
alias get set?
|
|
41
|
+
|
|
42
|
+
def clear
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@bytes = ("\x00" * @bytesize).b
|
|
45
|
+
end
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def count_ones
|
|
50
|
+
count = 0
|
|
51
|
+
@bytes.each_byte do |b|
|
|
52
|
+
# Kernighan's popcount per byte
|
|
53
|
+
while b > 0
|
|
54
|
+
count += 1
|
|
55
|
+
b &= (b - 1)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
count
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
alias popcount count_ones
|
|
62
|
+
|
|
63
|
+
def bytesize
|
|
64
|
+
@bytesize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_s
|
|
68
|
+
@mutex.synchronize { @bytes.dup }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.from_s(raw_bytes, size)
|
|
72
|
+
bit_array = new(size)
|
|
73
|
+
bit_array.to_s_load(raw_bytes)
|
|
74
|
+
bit_array
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_s_load(raw_bytes)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
raise InvalidArgumentError, "Byte length mismatch" if raw_bytes.bytesize != @bytesize
|
|
80
|
+
@bytes = raw_bytes.dup.b
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_index!(index)
|
|
87
|
+
raise IndexError, "index #{index} out of bounds (0...#{@size})" if index < 0 || index >= @size
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
module Bloom
|
|
8
|
+
class Counting
|
|
9
|
+
attr_reader :expected_elements, :false_positive_rate, :count, :counter_size
|
|
10
|
+
|
|
11
|
+
def initialize(expected_elements: 10_000, false_positive_rate: 0.001)
|
|
12
|
+
@expected_elements = expected_elements
|
|
13
|
+
@false_positive_rate = false_positive_rate
|
|
14
|
+
@count = 0
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
@bit_size = (-(expected_elements * Math.log(false_positive_rate)) / (Math.log(2)**2)).ceil
|
|
18
|
+
@hash_count = [((@bit_size.to_f / expected_elements) * Math.log(2)).round, 1].max
|
|
19
|
+
@counters = Array.new(@bit_size, 0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(element)
|
|
23
|
+
indexes = hash_indexes(element.to_s)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
indexes.each do |idx|
|
|
26
|
+
@counters[idx] += 1 if @counters[idx] < 255
|
|
27
|
+
end
|
|
28
|
+
@count += 1
|
|
29
|
+
end
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def contains?(element)
|
|
34
|
+
indexes = hash_indexes(element.to_s)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
indexes.all? { |idx| @counters[idx] > 0 }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete(element)
|
|
41
|
+
return false unless contains?(element)
|
|
42
|
+
|
|
43
|
+
indexes = hash_indexes(element.to_s)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
indexes.each do |idx|
|
|
46
|
+
@counters[idx] -= 1 if @counters[idx] > 0
|
|
47
|
+
end
|
|
48
|
+
@count = [@count - 1, 0].max
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@counters.fill(0)
|
|
56
|
+
@count = 0
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stats
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
{
|
|
64
|
+
type: :counting_bloom,
|
|
65
|
+
inserted_items: @count,
|
|
66
|
+
bit_size: @bit_size,
|
|
67
|
+
hash_count: @hash_count,
|
|
68
|
+
memory_usage_bytes: @counters.size
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def hash_indexes(key)
|
|
76
|
+
h1 = Zlib.crc32(key)
|
|
77
|
+
h2 = Digest::SHA256.hexdigest(key)[0..7].hex
|
|
78
|
+
|
|
79
|
+
Array.new(@hash_count) do |i|
|
|
80
|
+
(h1 + i * h2) % @bit_size
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
module Bloom
|
|
8
|
+
class Filter
|
|
9
|
+
attr_reader :expected_elements, :false_positive_rate, :bit_size, :hash_count, :count
|
|
10
|
+
|
|
11
|
+
def initialize(expected_elements: 1_000_000, false_positive_rate: 0.001, bit_array: nil)
|
|
12
|
+
raise InvalidArgumentError, "expected_elements must be > 0" if expected_elements <= 0
|
|
13
|
+
raise InvalidArgumentError, "false_positive_rate must be between 0 and 1" if false_positive_rate <= 0 || false_positive_rate >= 1
|
|
14
|
+
|
|
15
|
+
@expected_elements = expected_elements
|
|
16
|
+
@false_positive_rate = false_positive_rate
|
|
17
|
+
@count = 0
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
|
|
20
|
+
@bit_size = calculate_bit_size(expected_elements, false_positive_rate)
|
|
21
|
+
@hash_count = calculate_hash_count(@bit_size, expected_elements)
|
|
22
|
+
@bit_array = bit_array || FastExists::BitArray.new(@bit_size)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(element)
|
|
26
|
+
key = element.to_s
|
|
27
|
+
indexes = hash_indexes(key)
|
|
28
|
+
|
|
29
|
+
indexes.each do |idx|
|
|
30
|
+
@bit_array.set(idx)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@count += 1
|
|
35
|
+
end
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def contains?(element)
|
|
40
|
+
key = element.to_s
|
|
41
|
+
indexes = hash_indexes(key)
|
|
42
|
+
|
|
43
|
+
indexes.all? { |idx| @bit_array.set?(idx) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@bit_array.clear
|
|
49
|
+
@count = 0
|
|
50
|
+
end
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def capacity
|
|
55
|
+
@expected_elements
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def occupancy
|
|
59
|
+
set_bits = @bit_array.count_ones
|
|
60
|
+
set_bits.to_f / @bit_size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def estimated_false_positive_rate
|
|
64
|
+
# p = (1 - e^(-k * n / m))^k
|
|
65
|
+
exponent = -(@hash_count * @count.to_f) / @bit_size
|
|
66
|
+
(1.0 - Math::E**exponent)**@hash_count
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def memory_usage_bytes
|
|
70
|
+
@bit_array.bytesize
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stats
|
|
74
|
+
{
|
|
75
|
+
expected_elements: @expected_elements,
|
|
76
|
+
false_positive_rate: @false_positive_rate,
|
|
77
|
+
estimated_false_positive_rate: estimated_false_positive_rate,
|
|
78
|
+
inserted_items: @count,
|
|
79
|
+
bit_size: @bit_size,
|
|
80
|
+
hash_count: @hash_count,
|
|
81
|
+
occupancy: occupancy,
|
|
82
|
+
memory_usage_bytes: memory_usage_bytes
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dump
|
|
87
|
+
{
|
|
88
|
+
expected_elements: @expected_elements,
|
|
89
|
+
false_positive_rate: @false_positive_rate,
|
|
90
|
+
count: @count,
|
|
91
|
+
bit_size: @bit_size,
|
|
92
|
+
hash_count: @hash_count,
|
|
93
|
+
bytes: @bit_array.to_s
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.load(dump_data)
|
|
98
|
+
filter = new(
|
|
99
|
+
expected_elements: dump_data[:expected_elements],
|
|
100
|
+
false_positive_rate: dump_data[:false_positive_rate]
|
|
101
|
+
)
|
|
102
|
+
filter.instance_variable_set(:@count, dump_data[:count])
|
|
103
|
+
filter.bit_array.to_s_load(dump_data[:bytes])
|
|
104
|
+
filter
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
attr_reader :bit_array
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def calculate_bit_size(n, p)
|
|
112
|
+
# m = - (n * ln(p)) / (ln(2)^2)
|
|
113
|
+
m = (-(n * Math.log(p)) / (Math.log(2)**2)).ceil
|
|
114
|
+
[m, 64].max
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def calculate_hash_count(m, n)
|
|
118
|
+
# k = (m / n) * ln(2)
|
|
119
|
+
k = ((m.to_f / n) * Math.log(2)).round
|
|
120
|
+
[k, 1].max
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def hash_indexes(key)
|
|
124
|
+
h1 = Zlib.crc32(key)
|
|
125
|
+
h2 = Digest::SHA256.hexdigest(key)[0..7].hex
|
|
126
|
+
|
|
127
|
+
Array.new(@hash_count) do |i|
|
|
128
|
+
(h1 + i * h2) % @bit_size
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Bloom
|
|
5
|
+
class Scalable
|
|
6
|
+
attr_reader :initial_capacity, :false_positive_rate, :growth_factor, :tightening_ratio
|
|
7
|
+
|
|
8
|
+
def initialize(initial_capacity: 10_000, false_positive_rate: 0.001, growth_factor: 2, tightening_ratio: 0.8)
|
|
9
|
+
@initial_capacity = initial_capacity
|
|
10
|
+
@false_positive_rate = false_positive_rate
|
|
11
|
+
@growth_factor = growth_factor
|
|
12
|
+
@tightening_ratio = tightening_ratio
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
|
|
15
|
+
@filters = []
|
|
16
|
+
add_filter
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(element)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
current_filter = @filters.last
|
|
22
|
+
if current_filter.count >= current_filter.capacity
|
|
23
|
+
add_filter
|
|
24
|
+
current_filter = @filters.last
|
|
25
|
+
end
|
|
26
|
+
current_filter.add(element)
|
|
27
|
+
end
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def contains?(element)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@filters.any? { |filter| filter.contains?(element) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@filters.clear
|
|
40
|
+
add_filter
|
|
41
|
+
end
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def count
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@filters.sum(&:count)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def capacity
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
@filters.sum(&:capacity)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stats
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
{
|
|
60
|
+
type: :scalable_bloom,
|
|
61
|
+
filter_count: @filters.size,
|
|
62
|
+
inserted_items: count,
|
|
63
|
+
capacity: capacity,
|
|
64
|
+
memory_usage_bytes: @filters.sum(&:memory_usage_bytes)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def add_filter
|
|
72
|
+
idx = @filters.size
|
|
73
|
+
cap = (@initial_capacity * (@growth_factor**idx)).round
|
|
74
|
+
fp = @false_positive_rate * (@tightening_ratio**idx)
|
|
75
|
+
@filters << FastExists::Bloom::Filter.new(expected_elements: cap, false_positive_rate: fp)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
class CLI
|
|
8
|
+
def self.start(args = ARGV)
|
|
9
|
+
new(args).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(args)
|
|
13
|
+
@args = args.dup
|
|
14
|
+
@options = { format: :console }
|
|
15
|
+
parse_options!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
command = @args.shift
|
|
20
|
+
|
|
21
|
+
case command
|
|
22
|
+
when "stats"
|
|
23
|
+
puts FastExists.stats(format: @options[:format])
|
|
24
|
+
when "health"
|
|
25
|
+
health = FastExists.health!
|
|
26
|
+
if @options[:format] == :json
|
|
27
|
+
puts JSON.pretty_generate(health)
|
|
28
|
+
else
|
|
29
|
+
puts "Operational Health Status: #{health[:overall_status].to_s.upcase}"
|
|
30
|
+
health[:checks].each do |check|
|
|
31
|
+
mark = check[:status] == :pass ? "✓" : "⚠"
|
|
32
|
+
puts " #{mark} #{check[:name]}: #{check[:message]}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
when "analyze"
|
|
36
|
+
res = FastExists.analyze!(format: @options[:format])
|
|
37
|
+
puts res.is_a?(String) ? res : JSON.pretty_generate(res)
|
|
38
|
+
when "audit"
|
|
39
|
+
res = FastExists.audit!
|
|
40
|
+
puts JSON.pretty_generate(res)
|
|
41
|
+
when "doctor"
|
|
42
|
+
puts FastExists.doctor!(format: @options[:format])
|
|
43
|
+
when "report"
|
|
44
|
+
output_path = @options[:output] || default_output_path(@options[:format])
|
|
45
|
+
FastExists.report!(format: @options[:format], output: output_path)
|
|
46
|
+
when "benchmark"
|
|
47
|
+
require_relative "../../benchmarks/benchmark_suite"
|
|
48
|
+
FastExists::BenchmarkSuite.run!
|
|
49
|
+
when "rebuild"
|
|
50
|
+
model = @args.shift
|
|
51
|
+
attr = @args.shift
|
|
52
|
+
puts "Rebuilding #{model} #{attr}..."
|
|
53
|
+
when "verify"
|
|
54
|
+
puts "FastExists Filter Status: OK (0.00% FP Rate)"
|
|
55
|
+
when "version", "-v", "--version"
|
|
56
|
+
puts "fast_exists v#{FastExists::VERSION}"
|
|
57
|
+
else
|
|
58
|
+
puts_help
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse_options!
|
|
65
|
+
OptionParser.new do |opts|
|
|
66
|
+
opts.on("--json") { @options[:format] = :json }
|
|
67
|
+
opts.on("--yaml") { @options[:format] = :yaml }
|
|
68
|
+
opts.on("--html") { @options[:format] = :html }
|
|
69
|
+
opts.on("--markdown") { @options[:format] = :markdown }
|
|
70
|
+
opts.on("--pdf") { @options[:format] = :pdf }
|
|
71
|
+
opts.on("--csv") { @options[:format] = :csv }
|
|
72
|
+
opts.on("-o", "--output FILE") { |v| @options[:output] = v }
|
|
73
|
+
opts.on("-v", "--verbose") { @options[:verbose] = true }
|
|
74
|
+
opts.on("-q", "--quiet") { @options[:quiet] = true }
|
|
75
|
+
end.parse!(@args)
|
|
76
|
+
rescue OptionParser::InvalidOption => e
|
|
77
|
+
# Ignore unrecognized flags
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def default_output_path(fmt)
|
|
81
|
+
ext = case fmt
|
|
82
|
+
when :html then "html"
|
|
83
|
+
when :markdown then "md"
|
|
84
|
+
when :json then "json"
|
|
85
|
+
when :yaml then "yml"
|
|
86
|
+
when :csv then "csv"
|
|
87
|
+
when :pdf then "pdf"
|
|
88
|
+
else "txt"
|
|
89
|
+
end
|
|
90
|
+
"fast_exists_report.#{ext}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def puts_help
|
|
94
|
+
puts <<~HELP
|
|
95
|
+
FastExists CLI v#{FastExists::VERSION}
|
|
96
|
+
|
|
97
|
+
Performance Intelligence Suite Commands:
|
|
98
|
+
fast_exists stats Display runtime filter statistics
|
|
99
|
+
fast_exists health Run operational health check
|
|
100
|
+
fast_exists analyze Analyze Rails models & detect candidate attributes
|
|
101
|
+
fast_exists audit Perform deep architectural audit
|
|
102
|
+
fast_exists doctor Diagnose issues & generate code/config snippets
|
|
103
|
+
fast_exists report Generate comprehensive multi-format performance report
|
|
104
|
+
|
|
105
|
+
Maintenance Commands:
|
|
106
|
+
fast_exists rebuild <Model> Rebuild bloom filter for model
|
|
107
|
+
fast_exists verify <Model> Verify filter false positive rate
|
|
108
|
+
fast_exists benchmark Run performance benchmarks
|
|
109
|
+
fast_exists version Display version
|
|
110
|
+
|
|
111
|
+
Options:
|
|
112
|
+
--json, --yaml, --html, --markdown, --pdf, --csv
|
|
113
|
+
-o, --output <file>
|
|
114
|
+
HELP
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :backend,
|
|
6
|
+
:false_positive_rate,
|
|
7
|
+
:expected_elements,
|
|
8
|
+
:auto_sync,
|
|
9
|
+
:instrumentation,
|
|
10
|
+
:metrics,
|
|
11
|
+
:redis,
|
|
12
|
+
:file_path,
|
|
13
|
+
:logger,
|
|
14
|
+
:multi_tenant,
|
|
15
|
+
:tenant_strategy,
|
|
16
|
+
:tenant_thresholds,
|
|
17
|
+
:key_prefix
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@backend = :memory
|
|
21
|
+
@false_positive_rate = 0.001
|
|
22
|
+
@expected_elements = 1_000_000
|
|
23
|
+
@auto_sync = true
|
|
24
|
+
@instrumentation = true
|
|
25
|
+
@metrics = true
|
|
26
|
+
@redis = nil
|
|
27
|
+
@file_path = nil
|
|
28
|
+
@logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
|
|
29
|
+
|
|
30
|
+
# Multi-tenant enterprise configuration
|
|
31
|
+
@multi_tenant = false
|
|
32
|
+
@tenant_strategy = :adaptive
|
|
33
|
+
@tenant_thresholds = {
|
|
34
|
+
tiny: 10_000,
|
|
35
|
+
small: 100_000,
|
|
36
|
+
medium: 1_000_000
|
|
37
|
+
}
|
|
38
|
+
@key_prefix = "fast_exists"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset!
|
|
42
|
+
initialize
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Engine)
|
|
4
|
+
module FastExists
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace FastExists
|
|
7
|
+
|
|
8
|
+
routes.draw do
|
|
9
|
+
root to: "dashboard#index"
|
|
10
|
+
get "/stats", to: "dashboard#stats"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class DashboardController < ActionController::Base
|
|
15
|
+
def index
|
|
16
|
+
@stats = FastExists.stats
|
|
17
|
+
@advisor = FastExists::Optimizer::AiAdvisor.analyze
|
|
18
|
+
render html: dashboard_html.html_safe
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stats
|
|
22
|
+
render json: FastExists.stats
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def dashboard_html
|
|
28
|
+
<<-HTML
|
|
29
|
+
<!DOCTYPE html>
|
|
30
|
+
<html>
|
|
31
|
+
<head>
|
|
32
|
+
<title>FastExists Dashboard</title>
|
|
33
|
+
<style>
|
|
34
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 2rem; }
|
|
35
|
+
h1 { color: #38bdf8; font-size: 2.25rem; font-weight: 700; margin-bottom: 0.5rem; }
|
|
36
|
+
p.subtitle { color: #94a3b8; font-size: 1rem; margin-bottom: 2rem; }
|
|
37
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
|
|
38
|
+
.card { background: #1e293b; border-radius: 0.75rem; padding: 1.5rem; border: 1px solid #334155; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
|
|
39
|
+
.card-title { color: #94a3b8; font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
|
40
|
+
.card-val { font-size: 2rem; font-weight: 700; color: #38bdf8; }
|
|
41
|
+
.advice-card { background: #1e293b; border-left: 4px solid #38bdf8; padding: 1.5rem; border-radius: 0.5rem; margin-top: 2rem; }
|
|
42
|
+
.advice-title { font-weight: 700; font-size: 1.1rem; color: #e2e8f0; margin-bottom: 0.5rem; }
|
|
43
|
+
.advice-text { color: #cbd5e1; line-height: 1.6; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<h1>⚡ FastExists Dashboard</h1>
|
|
48
|
+
<p class="subtitle">Real-time Probabilistic Filter Performance & Statistics</p>
|
|
49
|
+
<div class="grid">
|
|
50
|
+
<div class="card"><div class="card-title">Queries Avoided</div><div class="card-val">#{@stats[:queries_avoided]}</div></div>
|
|
51
|
+
<div class="card"><div class="card-title">DB Lookups</div><div class="card-val">#{@stats[:database_lookups]}</div></div>
|
|
52
|
+
<div class="card"><div class="card-title">Bloom Hits</div><div class="card-val">#{@stats[:bloom_hits]}</div></div>
|
|
53
|
+
<div class="card"><div class="card-title">False Positives</div><div class="card-val">#{@stats[:false_positives]}</div></div>
|
|
54
|
+
<div class="card"><div class="card-title">Hit Ratio</div><div class="card-val">#{(@stats[:hit_ratio] * 100).round(1)}%</div></div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="advice-card">
|
|
57
|
+
<div class="advice-title">🤖 AI Optimizer Recommendation</div>
|
|
58
|
+
<div class="advice-text">#{@advisor[:recommendations][:advice]}</div>
|
|
59
|
+
</div>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
HTML
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigurationError < Error; end
|
|
6
|
+
class BackendError < Error; end
|
|
7
|
+
class UnsupportedBackendError < Error; end
|
|
8
|
+
class CapacityExceededError < Error; end
|
|
9
|
+
class InvalidArgumentError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Generators::Base)
|
|
4
|
+
module FastExists
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
def copy_initializer
|
|
10
|
+
template "fast_exists_initializer.rb", "config/initializers/fast_exists.rb"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
FastExists.configure do |config|
|
|
4
|
+
# Default backend storage (:memory, :redis, :redis_bloom, :file, :null)
|
|
5
|
+
config.backend = :memory
|
|
6
|
+
|
|
7
|
+
# Target false positive rate (0.1% default)
|
|
8
|
+
config.false_positive_rate = 0.001
|
|
9
|
+
|
|
10
|
+
# Initial expected elements per bloom filter
|
|
11
|
+
config.expected_elements = 1_000_000
|
|
12
|
+
|
|
13
|
+
# Automatic synchronization on record commit
|
|
14
|
+
config.auto_sync = true
|
|
15
|
+
|
|
16
|
+
# ActiveSupport::Notifications instrumentation
|
|
17
|
+
config.instrumentation = true
|
|
18
|
+
|
|
19
|
+
# Enable runtime metrics collection
|
|
20
|
+
config.metrics = true
|
|
21
|
+
|
|
22
|
+
# Optional Redis configuration
|
|
23
|
+
# config.redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
|
24
|
+
end
|