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
data/docs/SCALING.md ADDED
@@ -0,0 +1,74 @@
1
+ # 🏢 Enterprise Adaptive Multi-Tenant Bloom Filter Management
2
+
3
+ SaaS applications often serve thousands of tenants with dramatically different data sizes (e.g. 10 Enterprise customers vs 4,500 SMB customers).
4
+
5
+ - Allocating a dedicated Bloom filter per tenant wastes memory (4,500 Redis keys).
6
+ - Using a single global Bloom filter increases false positives and reduces operational flexibility.
7
+
8
+ `FastExists` provides **Adaptive Multi-Tenant Bloom Filter Management** (`:adaptive`), automatically classifying tenants into optimal pools based on record volume and lookup traffic.
9
+
10
+ ---
11
+
12
+ ## ⚙️ Configuration
13
+
14
+ ```ruby
15
+ # config/initializers/fast_exists.rb
16
+ FastExists.configure do |config|
17
+ config.backend = :redis
18
+ config.multi_tenant = true
19
+ config.tenant_strategy = :adaptive # :global, :per_tenant, :shared, :adaptive
20
+
21
+ # Configurable record count classification thresholds
22
+ config.tenant_thresholds = {
23
+ tiny: 10_000,
24
+ small: 100_000,
25
+ medium: 1_000_000
26
+ }
27
+ end
28
+ ```
29
+
30
+ ---
31
+
32
+ ## 🎯 Supported Strategies
33
+
34
+ 1. **`:global` (`GlobalStrategy`)**: Shared Bloom filter (`fast_exists:global`). Hashing `tenant_id:attribute:value`.
35
+ 2. **`:per_tenant` (`PerTenantStrategy`)**: Dedicated Bloom filter per tenant (`fast_exists:tenant:<id>`). Best for large Enterprise tenants (>1,000,000 records).
36
+ 3. **`:shared` (`SharedStrategy`)**: Configurable shared pools (`small`, `medium`, `large`).
37
+ 4. **`:adaptive` (`AdaptiveStrategy`)**: Recommended default. Automatically classifies tenants:
38
+ - `Tiny` (< 10,000 records) -> Shared `tiny` pool (`fast_exists:pool:tiny`)
39
+ - `Small` (10,000–100,000 records) -> Shared `small` pool (`fast_exists:pool:small`)
40
+ - `Medium` (100,000–1,000,000 records) -> Shared `medium` pool (`fast_exists:pool:medium`)
41
+ - `Large` (> 1,000,000 records) -> Dedicated filter (`fast_exists:tenant:<id>`)
42
+
43
+ ---
44
+
45
+ ## 🔑 Redis Key Layout
46
+
47
+ ```text
48
+ fast_exists:global
49
+ fast_exists:pool:tiny
50
+ fast_exists:pool:small
51
+ fast_exists:pool:medium
52
+ fast_exists:tenant:42
53
+ fast_exists:tenant:83
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 📈 Promotion & Demotion Recommendations
59
+
60
+ As tenants grow or shrink, `FastExists.doctor!` and `FastExists.analyze!` recommend promotion or demotion actions.
61
+
62
+ > [!IMPORTANT]
63
+ > **Zero Automatic Data Migration**: Recommendations are strictly read-only. Automatic rebalancing or migration never occurs without explicit developer action.
64
+
65
+ ---
66
+
67
+ ## 📊 Analytics & Doctor Output Example
68
+
69
+ For an application with 4,287 tenants (3,901 Tiny, 312 Small, 64 Medium, 10 Large):
70
+
71
+ - **Per-Tenant Strategy**: 4,287 Redis keys (~220 MB RAM)
72
+ - **Adaptive Strategy**: 13 Redis keys (~48 MB RAM)
73
+ - **Memory Reduction**: **78%**
74
+ - **Redis Key Reduction**: **99.7%**
@@ -0,0 +1,9 @@
1
+ # Troubleshooting Guide
2
+
3
+ ### Issue: High False Positive Rate
4
+ - **Symptom**: `FastExists.stats[:false_positive_rate]` is > 1%.
5
+ - **Solution**: Increase `expected_elements` in your initializer or run `FastExists::Optimizer::AiAdvisor.analyze(User)` to get recommended parameters.
6
+
7
+ ### Issue: Redis Connection Error
8
+ - **Symptom**: `FastExists::BackendError: Redis client not configured`.
9
+ - **Solution**: Set `config.redis = Redis.new(...)` in `config/initializers/fast_exists.rb`.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module ActiveRecord
5
+ module Extension
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def fast_exists(*attributes)
12
+ options = attributes.extract_options!
13
+
14
+ class_attribute :fast_exists_attributes, default: {} unless respond_to?(:fast_exists_attributes)
15
+
16
+ attributes.map(&:to_sym).each do |attr|
17
+ self.fast_exists_attributes = fast_exists_attributes.merge(attr => options)
18
+
19
+ # Generate attribute_exists?(val)
20
+ singleton_class.define_method("#{attr}_exists?") do |value|
21
+ fast_exists?(attr, value)
22
+ end
23
+
24
+ # Generate attribute_available?(val)
25
+ singleton_class.define_method("#{attr}_available?") do |value|
26
+ fast_available?(attr, value)
27
+ end
28
+ end
29
+
30
+ extend FastExists::ActiveRecord::ModelMethods
31
+ include FastExists::ActiveRecord::Hooks
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module ActiveRecord
5
+ module Hooks
6
+ def self.included(base)
7
+ base.after_commit :fast_exists_sync_on_commit, on: [:create, :update]
8
+ end
9
+
10
+ private
11
+
12
+ def fast_exists_sync_on_commit
13
+ return unless FastExists.configuration.auto_sync
14
+
15
+ self.class.fast_exists_attributes.each do |attr, options|
16
+ val = send(attr)
17
+ next if val.nil? || val.to_s.strip.empty?
18
+
19
+ backend = self.class.fast_exists_backend_for(attr)
20
+ backend.add(val.to_s)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module ActiveRecord
5
+ module ModelMethods
6
+ def fast_exists?(*args)
7
+ attribute, value = args.size == 2 ? args : [fast_exists_attributes.keys.first, args.first]
8
+ attribute = attribute.to_sym
9
+ raise InvalidArgumentError, "Attribute #{attribute} is not configured with fast_exists" unless fast_exists_attributes.key?(attribute)
10
+
11
+ backend = fast_exists_backend_for(attribute)
12
+ str_val = value.to_s
13
+
14
+ FastExists::Instrumentation::EventSubscriber.instrument(:lookup, model: name, attribute: attribute, value: str_val)
15
+
16
+ # Check probabilistic filter
17
+ if backend.contains?(str_val)
18
+ FastExists.stats_tracker.record_hit
19
+ FastExists.stats_tracker.record_db_lookup
20
+
21
+ # DB check
22
+ exists_in_db = where(attribute => value).exists?
23
+
24
+ if exists_in_db
25
+ FastExists::Instrumentation::EventSubscriber.instrument(:hit, model: name, attribute: attribute, value: str_val)
26
+ true
27
+ else
28
+ FastExists.stats_tracker.record_false_positive
29
+ FastExists::Instrumentation::EventSubscriber.instrument(:false_positive, model: name, attribute: attribute, value: str_val)
30
+ false
31
+ end
32
+ else
33
+ FastExists.stats_tracker.record_avoided
34
+ FastExists::Instrumentation::EventSubscriber.instrument(:miss, model: name, attribute: attribute, value: str_val)
35
+ false
36
+ end
37
+ end
38
+
39
+ def fast_available?(attribute, value = nil)
40
+ if value.nil?
41
+ value = attribute
42
+ attribute = fast_exists_attributes.keys.first
43
+ end
44
+ !fast_exists?(attribute, value)
45
+ end
46
+
47
+ def rebuild_fast_exists!(attribute = nil)
48
+ attributes_to_rebuild = attribute ? [attribute.to_sym] : fast_exists_attributes.keys
49
+
50
+ attributes_to_rebuild.each do |attr|
51
+ backend = fast_exists_backend_for(attr)
52
+ backend.clear
53
+
54
+ if respond_to?(:find_in_batches)
55
+ find_in_batches(batch_size: 5000) do |batch|
56
+ batch.each do |record|
57
+ val = record.send(attr)
58
+ backend.add(val.to_s) if val.present?
59
+ end
60
+ end
61
+ else
62
+ pluck(attr).compact.each do |val|
63
+ backend.add(val.to_s)
64
+ end
65
+ end
66
+ backend.save if backend.respond_to?(:save)
67
+ end
68
+ true
69
+ end
70
+
71
+ def rebuild_fast_exists_async!(attribute = nil)
72
+ if defined?(Sidekiq)
73
+ # Asynchronous sidekiq dispatch
74
+ FastExists::Jobs::RebuildJob.perform_async(name, attribute&.to_s)
75
+ else
76
+ rebuild_fast_exists!(attribute)
77
+ end
78
+ end
79
+
80
+ def fast_exists_stats(attribute = nil)
81
+ attributes = attribute ? [attribute.to_sym] : fast_exists_attributes.keys
82
+ stats_hash = {}
83
+
84
+ attributes.each do |attr|
85
+ backend = fast_exists_backend_for(attr)
86
+ stats_hash[attr] = backend.stats.merge(global_stats: FastExists.stats)
87
+ end
88
+
89
+ attribute ? stats_hash[attribute.to_sym] : stats_hash
90
+ end
91
+
92
+ def fast_exists_backend_for(attribute)
93
+ @fast_exists_backends ||= {}
94
+ @fast_exists_backends[attribute.to_sym] ||= begin
95
+ options = fast_exists_attributes[attribute.to_sym] || {}
96
+ backend_name = options[:backend] || FastExists.configuration.backend
97
+ namespace = options[:namespace] || "#{name.underscore}:#{attribute}"
98
+
99
+ resolved_ns = FastExists::MultiTenancy::Resolver.resolve(namespace)
100
+
101
+ FastExists.backends.fetch(
102
+ backend_name,
103
+ options.merge(namespace: resolved_ns)
104
+ )
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module Backends
5
+ class Base
6
+ attr_reader :options
7
+
8
+ def initialize(options = {})
9
+ @options = options
10
+ @expected_elements = options[:expected_elements] || FastExists.configuration.expected_elements
11
+ @false_positive_rate = options[:false_positive_rate] || FastExists.configuration.false_positive_rate
12
+ end
13
+
14
+ def add(key)
15
+ raise NotImplementedError, "#{self.class.name}#add is not implemented"
16
+ end
17
+
18
+ def contains?(key)
19
+ raise NotImplementedError, "#{self.class.name}#contains? is not implemented"
20
+ end
21
+
22
+ def clear
23
+ raise NotImplementedError, "#{self.class.name}#clear is not implemented"
24
+ end
25
+
26
+ def save
27
+ true
28
+ end
29
+
30
+ def load
31
+ true
32
+ end
33
+
34
+ def count
35
+ 0
36
+ end
37
+
38
+ def capacity
39
+ @expected_elements
40
+ end
41
+
42
+ def stats
43
+ {
44
+ backend: self.class.name,
45
+ capacity: capacity,
46
+ count: count
47
+ }
48
+ end
49
+
50
+ def rebuild(enumerable)
51
+ clear
52
+ enumerable.each do |item|
53
+ add(item)
54
+ end
55
+ save
56
+ true
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module FastExists
7
+ module Backends
8
+ class File < Base
9
+ attr_reader :file_path, :filter
10
+
11
+ def initialize(options = {})
12
+ super
13
+ @file_path = options[:file_path] || FastExists.configuration.file_path || "tmp/fast_exists_#{@options[:namespace] || 'default'}.bloom"
14
+ @filter = FastExists::Bloom::Filter.new(
15
+ expected_elements: @expected_elements,
16
+ false_positive_rate: @false_positive_rate
17
+ )
18
+ load if ::File.exist?(@file_path)
19
+ end
20
+
21
+ def add(key)
22
+ res = @filter.add(key)
23
+ save
24
+ res
25
+ end
26
+
27
+ def contains?(key)
28
+ @filter.contains?(key)
29
+ end
30
+
31
+ def clear
32
+ res = @filter.clear
33
+ ::File.delete(@file_path) if ::File.exist?(@file_path)
34
+ res
35
+ end
36
+
37
+ def count
38
+ @filter.count
39
+ end
40
+
41
+ def capacity
42
+ @filter.capacity
43
+ end
44
+
45
+ def save
46
+ FileUtils.mkdir_p(::File.dirname(@file_path))
47
+ dump = @filter.dump
48
+ temp_file = "#{@file_path}.tmp"
49
+ ::File.binwrite(temp_file, JSON.generate(dump))
50
+ ::File.rename(temp_file, @file_path)
51
+ true
52
+ rescue => e
53
+ raise BackendError, "Failed to save file backend: #{e.message}"
54
+ end
55
+
56
+ def load
57
+ return false unless ::File.exist?(@file_path)
58
+ data = JSON.parse(::File.binread(@file_path), symbolize_names: true)
59
+ @filter = FastExists::Bloom::Filter.load(data)
60
+ true
61
+ rescue => e
62
+ raise BackendError, "Failed to load file backend: #{e.message}"
63
+ end
64
+
65
+ def stats
66
+ @filter.stats.merge(backend: :file, file_path: @file_path)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module Backends
5
+ class Memory < Base
6
+ attr_reader :filter
7
+
8
+ def initialize(options = {})
9
+ super
10
+ @filter = FastExists::Bloom::Filter.new(
11
+ expected_elements: @expected_elements,
12
+ false_positive_rate: @false_positive_rate
13
+ )
14
+ end
15
+
16
+ def add(key)
17
+ @filter.add(key)
18
+ end
19
+
20
+ def contains?(key)
21
+ @filter.contains?(key)
22
+ end
23
+
24
+ def clear
25
+ @filter.clear
26
+ end
27
+
28
+ def count
29
+ @filter.count
30
+ end
31
+
32
+ def capacity
33
+ @filter.capacity
34
+ end
35
+
36
+ def stats
37
+ @filter.stats.merge(backend: :memory)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module Backends
5
+ class Null < Base
6
+ def add(_key)
7
+ true
8
+ end
9
+
10
+ def contains?(_key)
11
+ true # Always maybe present -> forces DB check (safe fallback)
12
+ end
13
+
14
+ def clear
15
+ true
16
+ end
17
+
18
+ def count
19
+ 0
20
+ end
21
+
22
+ def capacity
23
+ @expected_elements
24
+ end
25
+
26
+ def stats
27
+ { backend: :null, capacity: capacity, count: 0, status: :disabled }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "digest"
5
+
6
+ module FastExists
7
+ module Backends
8
+ class Redis < Base
9
+ attr_reader :key_prefix, :bit_size, :hash_count
10
+
11
+ def initialize(options = {})
12
+ super
13
+ @redis = options[:redis] || FastExists.configuration.redis
14
+ @key_prefix = options[:namespace] ? "fast_exists:#{options[:namespace]}" : "fast_exists:default"
15
+
16
+ # Bloom filter parameters calculation
17
+ n = @expected_elements
18
+ p = @false_positive_rate
19
+ @bit_size = (-(n * Math.log(p)) / (Math.log(2)**2)).ceil
20
+ @hash_count = [((@bit_size.to_f / n) * Math.log(2)).round, 1].max
21
+ @count_key = "#{@key_prefix}:count"
22
+ @bit_key = "#{@key_prefix}:bits"
23
+ end
24
+
25
+ def add(element)
26
+ indexes = hash_indexes(element.to_s)
27
+ execute_redis do |client|
28
+ indexes.each do |idx|
29
+ client.setbit(@bit_key, idx, 1)
30
+ end
31
+ client.incr(@count_key)
32
+ end
33
+ true
34
+ end
35
+
36
+ def contains?(element)
37
+ indexes = hash_indexes(element.to_s)
38
+ execute_redis do |client|
39
+ indexes.all? do |idx|
40
+ client.getbit(@bit_key, idx) == 1
41
+ end
42
+ end
43
+ end
44
+
45
+ def clear
46
+ execute_redis do |client|
47
+ client.del(@bit_key, @count_key)
48
+ end
49
+ true
50
+ end
51
+
52
+ def count
53
+ execute_redis do |client|
54
+ (client.get(@count_key) || 0).to_i
55
+ end
56
+ end
57
+
58
+ def capacity
59
+ @expected_elements
60
+ end
61
+
62
+ def stats
63
+ {
64
+ backend: :redis,
65
+ bit_key: @bit_key,
66
+ expected_elements: @expected_elements,
67
+ false_positive_rate: @false_positive_rate,
68
+ inserted_items: count,
69
+ bit_size: @bit_size,
70
+ hash_count: @hash_count
71
+ }
72
+ end
73
+
74
+ private
75
+
76
+ def execute_redis
77
+ if @redis.respond_to?(:with)
78
+ @redis.with { |conn| yield conn }
79
+ elsif @redis
80
+ yield @redis
81
+ elsif defined?(::Redis) && ::Redis.respond_to?(:current)
82
+ yield ::Redis.current
83
+ else
84
+ raise BackendError, "Redis client not configured. Set config.redis = Redis.new"
85
+ end
86
+ end
87
+
88
+ def hash_indexes(key)
89
+ h1 = Zlib.crc32(key)
90
+ h2 = Digest::SHA256.hexdigest(key)[0..7].hex
91
+
92
+ Array.new(@hash_count) do |i|
93
+ (h1 + i * h2) % @bit_size
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module Backends
5
+ class RedisBloom < Base
6
+ attr_reader :filter_key
7
+
8
+ def initialize(options = {})
9
+ super
10
+ @redis = options[:redis] || FastExists.configuration.redis
11
+ @filter_key = options[:namespace] ? "fast_exists:bf:#{options[:namespace]}" : "fast_exists:bf:default"
12
+ ensure_filter_reserved
13
+ end
14
+
15
+ def add(element)
16
+ execute_redis do |client|
17
+ client.call(["BF.ADD", @filter_key, element.to_s]) == 1
18
+ end
19
+ end
20
+
21
+ def contains?(element)
22
+ execute_redis do |client|
23
+ client.call(["BF.EXISTS", @filter_key, element.to_s]) == 1
24
+ end
25
+ end
26
+
27
+ def clear
28
+ execute_redis do |client|
29
+ client.del(@filter_key)
30
+ end
31
+ ensure_filter_reserved
32
+ true
33
+ end
34
+
35
+ def count
36
+ info = stats
37
+ info[:inserted_items] || 0
38
+ end
39
+
40
+ def capacity
41
+ @expected_elements
42
+ end
43
+
44
+ def stats
45
+ execute_redis do |client|
46
+ raw_info = client.call(["BF.INFO", @filter_key]) rescue []
47
+ info_hash = Hash[*raw_info] rescue {}
48
+ {
49
+ backend: :redis_bloom,
50
+ filter_key: @filter_key,
51
+ expected_elements: @expected_elements,
52
+ false_positive_rate: @false_positive_rate,
53
+ inserted_items: info_hash["Number of items inserted"] || 0,
54
+ capacity: info_hash["Capacity"] || @expected_elements,
55
+ bytes: info_hash["Size"] || 0
56
+ }
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_filter_reserved
63
+ execute_redis do |client|
64
+ client.call(["BF.RESERVE", @filter_key, @false_positive_rate, @expected_elements])
65
+ end
66
+ rescue => e
67
+ # Ignore if filter already exists
68
+ end
69
+
70
+ def execute_redis
71
+ if @redis.respond_to?(:with)
72
+ @redis.with { |conn| yield conn }
73
+ elsif @redis
74
+ yield @redis
75
+ elsif defined?(::Redis) && ::Redis.respond_to?(:current)
76
+ yield ::Redis.current
77
+ else
78
+ raise BackendError, "Redis client not configured. Set config.redis = Redis.new"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastExists
4
+ module Backends
5
+ class Registry
6
+ def initialize
7
+ @backends = {}
8
+ register_default_backends
9
+ end
10
+
11
+ def register(name, klass)
12
+ @backends[name.to_sym] = klass
13
+ end
14
+
15
+ def fetch(name, options = {})
16
+ backend_name = name.to_sym
17
+ klass = @backends[backend_name]
18
+ raise UnsupportedBackendError, "Backend '#{name}' is not registered" unless klass
19
+
20
+ klass.new(options)
21
+ end
22
+
23
+ def registered?(name)
24
+ @backends.key?(name.to_sym)
25
+ end
26
+
27
+ private
28
+
29
+ def register_default_backends
30
+ register(:memory, FastExists::Backends::Memory)
31
+ register(:file, FastExists::Backends::File)
32
+ register(:null, FastExists::Backends::Null)
33
+ register(:redis, FastExists::Backends::Redis)
34
+ register(:redis_bloom, FastExists::Backends::RedisBloom)
35
+ end
36
+ end
37
+ end
38
+ end