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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +242 -0
  4. data/benchmarks/benchmark_suite.rb +58 -0
  5. data/bin/fast_exists +5 -0
  6. data/docs/ADR/0001_probabilistic_data_structures.md +12 -0
  7. data/docs/ADR/0002_backend_abstraction.md +11 -0
  8. data/docs/ADR/0003_active_record_hooks.md +11 -0
  9. data/docs/ARCHITECTURE.md +36 -0
  10. data/docs/BENCHMARKS.md +9 -0
  11. data/docs/DEPLOYMENT.md +11 -0
  12. data/docs/FAQ.md +10 -0
  13. data/docs/MEMORY_SIZING.md +22 -0
  14. data/docs/PERFORMANCE.md +9 -0
  15. data/docs/PERFORMANCE_INTELLIGENCE.md +114 -0
  16. data/docs/REDIS_GUIDE.md +15 -0
  17. data/docs/SCALING.md +74 -0
  18. data/docs/TROUBLESHOOTING.md +9 -0
  19. data/lib/fast_exists/active_record/extension.rb +36 -0
  20. data/lib/fast_exists/active_record/hooks.rb +25 -0
  21. data/lib/fast_exists/active_record/model_methods.rb +109 -0
  22. data/lib/fast_exists/backends/base.rb +60 -0
  23. data/lib/fast_exists/backends/file.rb +70 -0
  24. data/lib/fast_exists/backends/memory.rb +41 -0
  25. data/lib/fast_exists/backends/null.rb +31 -0
  26. data/lib/fast_exists/backends/redis.rb +98 -0
  27. data/lib/fast_exists/backends/redis_bloom.rb +83 -0
  28. data/lib/fast_exists/backends/registry.rb +38 -0
  29. data/lib/fast_exists/bit_array.rb +90 -0
  30. data/lib/fast_exists/bloom/counting.rb +85 -0
  31. data/lib/fast_exists/bloom/filter.rb +133 -0
  32. data/lib/fast_exists/bloom/scalable.rb +79 -0
  33. data/lib/fast_exists/cli.rb +117 -0
  34. data/lib/fast_exists/configuration.rb +45 -0
  35. data/lib/fast_exists/engine.rb +66 -0
  36. data/lib/fast_exists/errors.rb +10 -0
  37. data/lib/fast_exists/generators/install_generator.rb +15 -0
  38. data/lib/fast_exists/generators/templates/fast_exists_initializer.rb +24 -0
  39. data/lib/fast_exists/instrumentation/event_subscriber.rb +35 -0
  40. data/lib/fast_exists/instrumentation/open_telemetry.rb +18 -0
  41. data/lib/fast_exists/instrumentation/prometheus.rb +32 -0
  42. data/lib/fast_exists/intelligence/analyzer.rb +135 -0
  43. data/lib/fast_exists/intelligence/auditor.rb +116 -0
  44. data/lib/fast_exists/intelligence/data_model.rb +57 -0
  45. data/lib/fast_exists/intelligence/doctor.rb +190 -0
  46. data/lib/fast_exists/intelligence/health.rb +109 -0
  47. data/lib/fast_exists/intelligence/report/builder.rb +53 -0
  48. data/lib/fast_exists/intelligence/report/console.rb +40 -0
  49. data/lib/fast_exists/intelligence/report/csv.rb +37 -0
  50. data/lib/fast_exists/intelligence/report/html.rb +118 -0
  51. data/lib/fast_exists/intelligence/report/json.rb +17 -0
  52. data/lib/fast_exists/intelligence/report/markdown.rb +41 -0
  53. data/lib/fast_exists/intelligence/report/pdf.rb +43 -0
  54. data/lib/fast_exists/intelligence/report/yaml.rb +17 -0
  55. data/lib/fast_exists/intelligence/stats.rb +78 -0
  56. data/lib/fast_exists/multi_tenancy/resolver.rb +24 -0
  57. data/lib/fast_exists/multi_tenant/allocator.rb +43 -0
  58. data/lib/fast_exists/multi_tenant/base.rb +25 -0
  59. data/lib/fast_exists/multi_tenant/key_layout.rb +19 -0
  60. data/lib/fast_exists/multi_tenant/pool.rb +42 -0
  61. data/lib/fast_exists/multi_tenant/recommendation_engine.rb +76 -0
  62. data/lib/fast_exists/multi_tenant/strategies/adaptive_strategy.rb +81 -0
  63. data/lib/fast_exists/multi_tenant/strategies/base_strategy.rb +31 -0
  64. data/lib/fast_exists/multi_tenant/strategies/global_strategy.rb +37 -0
  65. data/lib/fast_exists/multi_tenant/strategies/per_tenant_strategy.rb +54 -0
  66. data/lib/fast_exists/multi_tenant/strategies/shared_strategy.rb +52 -0
  67. data/lib/fast_exists/optimizer/ai_advisor.rb +52 -0
  68. data/lib/fast_exists/probabilistic/count_min_sketch.rb +67 -0
  69. data/lib/fast_exists/probabilistic/cuckoo.rb +128 -0
  70. data/lib/fast_exists/probabilistic/hyper_log_log.rb +84 -0
  71. data/lib/fast_exists/railtie.rb +17 -0
  72. data/lib/fast_exists/statistics/tracker.rb +83 -0
  73. data/lib/fast_exists/tasks/fast_exists.rake +117 -0
  74. data/lib/fast_exists/version.rb +5 -0
  75. data/lib/fast_exists.rb +131 -0
  76. 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