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
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
|