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,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
class RecommendationEngine
|
|
6
|
+
def self.analyze(tenant_records = {})
|
|
7
|
+
new(tenant_records).generate_recommendations
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(tenant_records = {})
|
|
11
|
+
@tenant_records = tenant_records
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def generate_recommendations
|
|
15
|
+
total_tenants = @tenant_records.size
|
|
16
|
+
|
|
17
|
+
counts = { tiny: 0, small: 0, medium: 0, large: 0 }
|
|
18
|
+
promotions = []
|
|
19
|
+
demotions = []
|
|
20
|
+
|
|
21
|
+
adaptive_strategy = FastExists::MultiTenant::Strategies::AdaptiveStrategy.new
|
|
22
|
+
|
|
23
|
+
@tenant_records.each do |tenant_id, record_count|
|
|
24
|
+
cat = adaptive_strategy.classify_tenant(tenant_id, record_count)
|
|
25
|
+
counts[cat] += 1
|
|
26
|
+
|
|
27
|
+
# Promotion check (e.g. record count grew past bucket limit)
|
|
28
|
+
if cat == :large
|
|
29
|
+
promotions << {
|
|
30
|
+
tenant_id: tenant_id,
|
|
31
|
+
current_category: :medium,
|
|
32
|
+
recommended_category: :large,
|
|
33
|
+
reason: "Tenant record count (#{record_count}) exceeded 1,000,000 threshold. Recommend dedicated Bloom filter."
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
current_strategy = FastExists.configuration.tenant_strategy
|
|
39
|
+
|
|
40
|
+
# Calculate estimated Redis key reduction
|
|
41
|
+
per_tenant_keys = [total_tenants, 1].max
|
|
42
|
+
adaptive_keys = 3 + counts[:large] # tiny, small, medium pools + dedicated large filters
|
|
43
|
+
|
|
44
|
+
key_reduction_pct = per_tenant_keys > 0 ? (((per_tenant_keys - adaptive_keys).to_f / per_tenant_keys) * 100).round(1) : 0.0
|
|
45
|
+
memory_savings_pct = current_strategy == :per_tenant ? 78.0 : 0.0
|
|
46
|
+
|
|
47
|
+
recommended_strategy = (counts[:tiny] + counts[:small] > 0.7 * [total_tenants, 1].max) ? :adaptive : current_strategy
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
total_tenants: total_tenants,
|
|
51
|
+
buckets: counts,
|
|
52
|
+
current_strategy: current_strategy,
|
|
53
|
+
recommended_strategy: recommended_strategy,
|
|
54
|
+
estimated_memory_savings_pct: [memory_savings_pct, 0.0].max,
|
|
55
|
+
estimated_redis_keys: adaptive_keys,
|
|
56
|
+
redis_key_reduction_pct: [key_reduction_pct, 0.0].max,
|
|
57
|
+
promotions: promotions,
|
|
58
|
+
demotions: demotions,
|
|
59
|
+
advice: generate_advice(current_strategy, recommended_strategy, total_tenants, adaptive_keys, key_reduction_pct)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def generate_advice(current, recommended, total, keys, key_reduction)
|
|
66
|
+
if current == :per_tenant && recommended == :adaptive
|
|
67
|
+
"Current strategy is :per_tenant with #{total} tenants. Switching to :adaptive strategy will reduce Redis keys from #{total} to #{keys} (#{key_reduction}% reduction) and save ~78% memory with no measurable lookup degradation."
|
|
68
|
+
elsif current == :global && total > 500
|
|
69
|
+
"Current strategy is :global with #{total} tenants. Consider switching to :adaptive strategy to reduce false positive interference between large and small tenants."
|
|
70
|
+
else
|
|
71
|
+
"Current strategy :#{current} is optimal for #{total} active tenants."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
module Strategies
|
|
6
|
+
class AdaptiveStrategy < BaseStrategy
|
|
7
|
+
attr_reader :pools, :per_tenant_strategy
|
|
8
|
+
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
super
|
|
11
|
+
@thresholds = options[:thresholds] || FastExists.configuration.tenant_thresholds
|
|
12
|
+
@pools = {
|
|
13
|
+
tiny: FastExists::MultiTenant::Pool.new(name: "tiny", expected_elements: 200_000),
|
|
14
|
+
small: FastExists::MultiTenant::Pool.new(name: "small", expected_elements: 1_000_000),
|
|
15
|
+
medium: FastExists::MultiTenant::Pool.new(name: "medium", expected_elements: 5_000_000)
|
|
16
|
+
}
|
|
17
|
+
@per_tenant_strategy = FastExists::MultiTenant::Strategies::PerTenantStrategy.new(options)
|
|
18
|
+
@tenant_classification_cache = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(tenant_id, attribute, value)
|
|
23
|
+
category = tenant_category(tenant_id)
|
|
24
|
+
if category == :large
|
|
25
|
+
@per_tenant_strategy.add(tenant_id, attribute, value)
|
|
26
|
+
else
|
|
27
|
+
pool = @pools[category] || @pools[:tiny]
|
|
28
|
+
pool.add(tenant_id, attribute, value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def contains?(tenant_id, attribute, value)
|
|
33
|
+
category = tenant_category(tenant_id)
|
|
34
|
+
if category == :large
|
|
35
|
+
@per_tenant_strategy.contains?(tenant_id, attribute, value)
|
|
36
|
+
else
|
|
37
|
+
pool = @pools[category] || @pools[:tiny]
|
|
38
|
+
pool.contains?(tenant_id, attribute, value)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def classify_tenant(_tenant_id, record_count)
|
|
43
|
+
tiny_max = @thresholds[:tiny] || 10_000
|
|
44
|
+
small_max = @thresholds[:small] || 100_000
|
|
45
|
+
medium_max = @thresholds[:medium] || 1_000_000
|
|
46
|
+
|
|
47
|
+
case record_count
|
|
48
|
+
when 0...tiny_max then :tiny
|
|
49
|
+
when tiny_max...small_max then :small
|
|
50
|
+
when small_max...medium_max then :medium
|
|
51
|
+
else :large
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set_tenant_category(tenant_id, category)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@tenant_classification_cache[tenant_id.to_s] = category.to_sym
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tenant_category(tenant_id)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@tenant_classification_cache[tenant_id.to_s] || :tiny
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stats
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
{
|
|
70
|
+
strategy: :adaptive,
|
|
71
|
+
classified_tenants: @tenant_classification_cache.size,
|
|
72
|
+
distribution: @tenant_classification_cache.values.tally,
|
|
73
|
+
pools: @pools.transform_values(&:stats),
|
|
74
|
+
dedicated_filters: @per_tenant_strategy.stats
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
module Strategies
|
|
6
|
+
class BaseStrategy
|
|
7
|
+
attr_reader :options
|
|
8
|
+
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(tenant_id, attribute, value)
|
|
14
|
+
raise NotImplementedError, "#{self.class.name}#add is not implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def contains?(tenant_id, attribute, value)
|
|
18
|
+
raise NotImplementedError, "#{self.class.name}#contains? is not implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def classify_tenant(tenant_id, record_count)
|
|
22
|
+
:default
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stats
|
|
26
|
+
{ strategy: self.class.name }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
module Strategies
|
|
6
|
+
class GlobalStrategy < BaseStrategy
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
super
|
|
9
|
+
@backend = FastExists.backends.fetch(
|
|
10
|
+
FastExists.configuration.backend,
|
|
11
|
+
namespace: "global",
|
|
12
|
+
expected_elements: options[:expected_elements] || FastExists.configuration.expected_elements,
|
|
13
|
+
false_positive_rate: options[:false_positive_rate] || FastExists.configuration.false_positive_rate
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(tenant_id, attribute, value)
|
|
18
|
+
composite_key = "#{tenant_id}:#{attribute}:#{value}"
|
|
19
|
+
@backend.add(composite_key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def contains?(tenant_id, attribute, value)
|
|
23
|
+
composite_key = "#{tenant_id}:#{attribute}:#{value}"
|
|
24
|
+
@backend.contains?(composite_key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def classify_tenant(_tenant_id, _record_count)
|
|
28
|
+
:global
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stats
|
|
32
|
+
@backend.stats.merge(strategy: :global, key: FastExists::MultiTenant::KeyLayout.global_key)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
module Strategies
|
|
6
|
+
class PerTenantStrategy < BaseStrategy
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
super
|
|
9
|
+
@tenant_backends = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(tenant_id, attribute, value)
|
|
14
|
+
backend = tenant_backend_for(tenant_id)
|
|
15
|
+
composite_key = "#{attribute}:#{value}"
|
|
16
|
+
backend.add(composite_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def contains?(tenant_id, attribute, value)
|
|
20
|
+
backend = tenant_backend_for(tenant_id)
|
|
21
|
+
composite_key = "#{attribute}:#{value}"
|
|
22
|
+
backend.contains?(composite_key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def classify_tenant(_tenant_id, _record_count)
|
|
26
|
+
:per_tenant
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stats
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
{
|
|
32
|
+
strategy: :per_tenant,
|
|
33
|
+
active_tenant_filters: @tenant_backends.size,
|
|
34
|
+
tenant_keys: @tenant_backends.keys.map { |t| FastExists::MultiTenant::KeyLayout.tenant_key(t) }
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def tenant_backend_for(tenant_id)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@tenant_backends[tenant_id.to_s] ||= FastExists.backends.fetch(
|
|
44
|
+
FastExists.configuration.backend,
|
|
45
|
+
namespace: "tenant:#{tenant_id}",
|
|
46
|
+
expected_elements: options[:expected_elements] || FastExists.configuration.expected_elements,
|
|
47
|
+
false_positive_rate: options[:false_positive_rate] || FastExists.configuration.false_positive_rate
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module MultiTenant
|
|
5
|
+
module Strategies
|
|
6
|
+
class SharedStrategy < BaseStrategy
|
|
7
|
+
attr_reader :pools
|
|
8
|
+
|
|
9
|
+
def initialize(options = {})
|
|
10
|
+
super
|
|
11
|
+
@pools = {
|
|
12
|
+
small: FastExists::MultiTenant::Pool.new(name: "small", expected_elements: 500_000),
|
|
13
|
+
medium: FastExists::MultiTenant::Pool.new(name: "medium", expected_elements: 2_000_000),
|
|
14
|
+
large: FastExists::MultiTenant::Pool.new(name: "large", expected_elements: 10_000_000)
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(tenant_id, attribute, value)
|
|
19
|
+
pool = pool_for(tenant_id)
|
|
20
|
+
pool.add(tenant_id, attribute, value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def contains?(tenant_id, attribute, value)
|
|
24
|
+
pool = pool_for(tenant_id)
|
|
25
|
+
pool.contains?(tenant_id, attribute, value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def classify_tenant(_tenant_id, record_count)
|
|
29
|
+
case record_count
|
|
30
|
+
when 0..100_000 then :small
|
|
31
|
+
when 100_001..1_000_000 then :medium
|
|
32
|
+
else :large
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stats
|
|
37
|
+
{
|
|
38
|
+
strategy: :shared,
|
|
39
|
+
pools: @pools.transform_values(&:stats)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def pool_for(tenant_id)
|
|
46
|
+
# Default to medium pool if unclassified
|
|
47
|
+
@pools[:medium]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FastExists
|
|
4
|
+
module Optimizer
|
|
5
|
+
class AiAdvisor
|
|
6
|
+
def self.analyze(model_class = nil, attribute = nil)
|
|
7
|
+
stats = FastExists.stats
|
|
8
|
+
db_count = model_class ? (model_class.count rescue 100_000) : 100_000
|
|
9
|
+
|
|
10
|
+
recommended_fp = stats[:false_positive_rate] > 0.01 ? 0.001 : 0.0005
|
|
11
|
+
recommended_capacity = (db_count * 1.5).ceil
|
|
12
|
+
|
|
13
|
+
# Optimal m calculation: m = - (n * ln(p)) / (ln(2)^2)
|
|
14
|
+
optimal_bits = (-(recommended_capacity * Math.log(recommended_fp)) / (Math.log(2)**2)).ceil
|
|
15
|
+
optimal_hashes = [((optimal_bits.to_f / recommended_capacity) * Math.log(2)).round, 1].max
|
|
16
|
+
|
|
17
|
+
recommended_backend = case db_count
|
|
18
|
+
when 0..100_000 then :memory
|
|
19
|
+
when 100_001..5_000_000 then :redis
|
|
20
|
+
else :redis_bloom
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
current_stats: stats,
|
|
25
|
+
recommendations: {
|
|
26
|
+
false_positive_rate: recommended_fp,
|
|
27
|
+
expected_elements: recommended_capacity,
|
|
28
|
+
optimal_bit_size: optimal_bits,
|
|
29
|
+
optimal_hash_count: optimal_hashes,
|
|
30
|
+
recommended_backend: recommended_backend,
|
|
31
|
+
estimated_memory_mb: (optimal_bits / 8.0 / 1_048_576.0).round(2),
|
|
32
|
+
advice: generate_advice(stats, db_count, recommended_backend)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def self.generate_advice(stats, db_count, backend)
|
|
40
|
+
adv = []
|
|
41
|
+
if stats[:false_positive_rate] > 0.01
|
|
42
|
+
adv << "False positive rate is high (#{stats[:false_positive_rate] * 100}%). Increase expected_elements or reduce false_positive_rate target."
|
|
43
|
+
end
|
|
44
|
+
if stats[:hit_ratio] > 0.8
|
|
45
|
+
adv << "High Bloom filter hit ratio (#{stats[:hit_ratio] * 100}%). Most lookups are positive. Check if existence queries are necessary before writing."
|
|
46
|
+
end
|
|
47
|
+
adv << "Recommended backend for dataset of #{db_count} records is :#{backend}."
|
|
48
|
+
adv.join(" ")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
module Probabilistic
|
|
8
|
+
class CountMinSketch
|
|
9
|
+
attr_reader :width, :depth
|
|
10
|
+
|
|
11
|
+
def initialize(epsilon: 0.001, confidence: 0.99)
|
|
12
|
+
@width = (Math::E / epsilon).ceil
|
|
13
|
+
@depth = Math.log(1.0 / (1.0 - confidence)).ceil
|
|
14
|
+
@table = Array.new(@depth) { Array.new(@width, 0) }
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(element, count = 1)
|
|
19
|
+
key = element.to_s
|
|
20
|
+
indexes = hash_indexes(key)
|
|
21
|
+
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
indexes.each_with_index do |col, row|
|
|
24
|
+
@table[row][col] += count
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def estimate(element)
|
|
31
|
+
key = element.to_s
|
|
32
|
+
indexes = hash_indexes(key)
|
|
33
|
+
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
indexes.each_with_index.map { |col, row| @table[row][col] }.min
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@table.each { |row| row.fill(0) }
|
|
42
|
+
end
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stats
|
|
47
|
+
{
|
|
48
|
+
type: :count_min_sketch,
|
|
49
|
+
width: @width,
|
|
50
|
+
depth: @depth,
|
|
51
|
+
memory_cells: @width * @depth
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def hash_indexes(key)
|
|
58
|
+
h1 = Zlib.crc32(key)
|
|
59
|
+
h2 = Digest::SHA256.hexdigest(key)[0..7].hex
|
|
60
|
+
|
|
61
|
+
Array.new(@depth) do |i|
|
|
62
|
+
(h1 + i * h2) % @width
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
module Probabilistic
|
|
8
|
+
class Cuckoo
|
|
9
|
+
attr_reader :capacity, :bucket_size, :fingerprint_size, :count
|
|
10
|
+
|
|
11
|
+
def initialize(capacity: 10_000, bucket_size: 4, max_kicks: 500)
|
|
12
|
+
@capacity = capacity
|
|
13
|
+
@bucket_size = bucket_size
|
|
14
|
+
@max_kicks = max_kicks
|
|
15
|
+
@num_buckets = (capacity.to_f / bucket_size).ceil
|
|
16
|
+
@num_buckets = 1 if @num_buckets < 1
|
|
17
|
+
@buckets = Array.new(@num_buckets) { [] }
|
|
18
|
+
@count = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add(element)
|
|
23
|
+
fp = fingerprint(element.to_s)
|
|
24
|
+
i1 = hash_index(element.to_s)
|
|
25
|
+
i2 = alt_index(i1, fp)
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
if @buckets[i1].size < @bucket_size
|
|
29
|
+
@buckets[i1] << fp
|
|
30
|
+
@count += 1
|
|
31
|
+
return true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if @buckets[i2].size < @bucket_size
|
|
35
|
+
@buckets[i2] << fp
|
|
36
|
+
@count += 1
|
|
37
|
+
return true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Kicking eviction loop
|
|
41
|
+
curr_idx = rand(2) == 0 ? i1 : i2
|
|
42
|
+
curr_fp = fp
|
|
43
|
+
|
|
44
|
+
@max_kicks.times do
|
|
45
|
+
evicted_fp = @buckets[curr_idx][rand(@buckets[curr_idx].size)]
|
|
46
|
+
idx_in_bucket = @buckets[curr_idx].index(evicted_fp)
|
|
47
|
+
@buckets[curr_idx][idx_in_bucket] = curr_fp
|
|
48
|
+
|
|
49
|
+
curr_fp = evicted_fp
|
|
50
|
+
curr_idx = alt_index(curr_idx, curr_fp)
|
|
51
|
+
|
|
52
|
+
if @buckets[curr_idx].size < @bucket_size
|
|
53
|
+
@buckets[curr_idx] << curr_fp
|
|
54
|
+
@count += 1
|
|
55
|
+
return true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
raise CapacityExceededError, "Cuckoo filter is full"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def contains?(element)
|
|
64
|
+
fp = fingerprint(element.to_s)
|
|
65
|
+
i1 = hash_index(element.to_s)
|
|
66
|
+
i2 = alt_index(i1, fp)
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
@buckets[i1].include?(fp) || @buckets[i2].include?(fp)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete(element)
|
|
74
|
+
fp = fingerprint(element.to_s)
|
|
75
|
+
i1 = hash_index(element.to_s)
|
|
76
|
+
i2 = alt_index(i1, fp)
|
|
77
|
+
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
if (idx = @buckets[i1].index(fp))
|
|
80
|
+
@buckets[i1].delete_at(idx)
|
|
81
|
+
@count -= 1
|
|
82
|
+
return true
|
|
83
|
+
elsif (idx = @buckets[i2].index(fp))
|
|
84
|
+
@buckets[i2].delete_at(idx)
|
|
85
|
+
@count -= 1
|
|
86
|
+
return true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clear
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
@buckets.each(&:clear)
|
|
95
|
+
@count = 0
|
|
96
|
+
end
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stats
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
{
|
|
103
|
+
type: :cuckoo,
|
|
104
|
+
inserted_items: @count,
|
|
105
|
+
capacity: @capacity,
|
|
106
|
+
buckets: @num_buckets,
|
|
107
|
+
bucket_size: @bucket_size
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def fingerprint(key)
|
|
115
|
+
Digest::MD5.digest(key)[0..1]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def hash_index(key)
|
|
119
|
+
Zlib.crc32(key) % @num_buckets
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def alt_index(index, fp)
|
|
123
|
+
fp_hash = Zlib.crc32(fp)
|
|
124
|
+
(index ^ fp_hash) % @num_buckets
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module FastExists
|
|
7
|
+
module Probabilistic
|
|
8
|
+
class HyperLogLog
|
|
9
|
+
attr_reader :p, :m
|
|
10
|
+
|
|
11
|
+
def initialize(p: 14)
|
|
12
|
+
raise InvalidArgumentError, "p must be between 4 and 16" if p < 4 || p > 16
|
|
13
|
+
|
|
14
|
+
@p = p
|
|
15
|
+
@m = 1 << p
|
|
16
|
+
@registers = Array.new(@m, 0)
|
|
17
|
+
@alpha = calculate_alpha(@m)
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add(element)
|
|
22
|
+
hash = Digest::SHA256.hexdigest(element.to_s)[0..15].hex
|
|
23
|
+
idx = hash >> (64 - @p)
|
|
24
|
+
w = hash & ((1 << (64 - @p)) - 1)
|
|
25
|
+
rho = leading_zeros(w, 64 - @p) + 1
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@registers[idx] = [@registers[idx], rho].max
|
|
29
|
+
end
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def count
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
raw_estimate = @alpha * (@m**2) * (1.0 / @registers.sum { |r| 2.0**(-r) })
|
|
36
|
+
|
|
37
|
+
if raw_estimate <= 2.5 * @m
|
|
38
|
+
zeros = @registers.count(0)
|
|
39
|
+
zeros > 0 ? @m * Math.log(@m.to_f / zeros) : raw_estimate
|
|
40
|
+
else
|
|
41
|
+
raw_estimate
|
|
42
|
+
end.round
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@registers.fill(0)
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stats
|
|
54
|
+
{
|
|
55
|
+
type: :hyper_log_log,
|
|
56
|
+
registers: @m,
|
|
57
|
+
precision_p: @p,
|
|
58
|
+
estimated_cardinality: count
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def leading_zeros(val, bit_len)
|
|
65
|
+
return bit_len if val == 0
|
|
66
|
+
clz = 0
|
|
67
|
+
(bit_len - 1).downto(0) do |bit|
|
|
68
|
+
break if (val & (1 << bit)) != 0
|
|
69
|
+
clz += 1
|
|
70
|
+
end
|
|
71
|
+
clz
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def calculate_alpha(m)
|
|
75
|
+
case m
|
|
76
|
+
when 16 then 0.673
|
|
77
|
+
when 32 then 0.697
|
|
78
|
+
when 64 then 0.709
|
|
79
|
+
else 0.7213 / (1.0 + 1.079 / m)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Railtie)
|
|
4
|
+
module FastExists
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
initializer "fast_exists.active_record" do
|
|
7
|
+
ActiveSupport.on_load(:active_record) do
|
|
8
|
+
include FastExists::ActiveRecord::Extension
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
rake_tasks do
|
|
13
|
+
load File.expand_path("tasks/fast_exists.rake", __dir__)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|