type_balancer_rails 0.2.6 → 0.2.8
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 +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +35 -1
- data/README.md +108 -0
- data/benchmarks/collection_methods_benchmark.rb +43 -0
- data/docs/architecture_overview.md +148 -0
- data/example/Gemfile.lock +3 -3
- data/example/config/environments/test.rb +1 -1
- data/example/spec/controllers/posts_controller_caching_spec.rb +65 -0
- data/example/spec/features/contents_balancing_spec.rb +1 -1
- data/example/spec/models/content_spec.rb +1 -0
- data/example/spec/models/post_spec.rb +1 -0
- data/example/storage/test.sqlite3 +0 -0
- data/lib/type_balancer/rails/cache_adapter.rb +38 -0
- data/lib/type_balancer/rails/collection_methods.rb +44 -58
- data/lib/type_balancer/rails/version.rb +1 -1
- data/lib/type_balancer/rails.rb +15 -0
- data/type_balancer_rails.gemspec +1 -1
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9866aa8259f829d620cf73c8646ca94d0df346fb919f2b9c162541e0206e39d7
|
4
|
+
data.tar.gz: 1869524e7b9660adae5e6a86e8e6807306d61ad6927ad2192005b12236f2ef09
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 98609db2225b4e762f7445bcc2fcf675e7995c49b1c41699ae5e5c2c706f34e0dbe88945b6bcf72f0651c2f92e2648c0515bda048df0631dd322e0abf3caaa5e
|
7
|
+
data.tar.gz: 2f8371a767e3301c57ffb3cf9bc16ae9e9e5e6ffe381ef97230632837eef7e9157f2968e40735fc00c8a2662e1f5dc56f5fb12644ed298b36a6162e0c5fe1d74
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,38 @@
|
|
1
|
-
## [
|
1
|
+
## [0.2.8] - 2025-05-10
|
2
|
+
|
3
|
+
- **Rails-style configuration block:**
|
4
|
+
You can now configure TypeBalancer Rails in an initializer using:
|
5
|
+
```ruby
|
6
|
+
TypeBalancer::Rails.configure do |config|
|
7
|
+
config.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
|
8
|
+
config.cache_expiry_seconds = 600
|
9
|
+
end
|
10
|
+
```
|
11
|
+
Direct assignment is still supported for backward compatibility.
|
12
|
+
|
13
|
+
- **Per-request cache control:**
|
14
|
+
`balance_by_type` now accepts:
|
15
|
+
- `expires_in:` (override cache expiry for a single call)
|
16
|
+
- `cache_reset:` (force cache refresh for a single call)
|
17
|
+
|
18
|
+
- **Global cache expiry configuration:**
|
19
|
+
Set the default cache expiry for all balanced queries via `TypeBalancer::Rails.cache_expiry_seconds`.
|
20
|
+
|
21
|
+
- **Cache clearing:**
|
22
|
+
Use `TypeBalancer::Rails.clear_cache!` to clear all cached balanced results (e.g., from a console or admin task).
|
23
|
+
|
24
|
+
### Changed
|
25
|
+
- **Caching and pagination are always enabled** for performance and reliability.
|
26
|
+
- **Cache keys are now isolated** per model and type field, preventing cross-contamination.
|
27
|
+
|
28
|
+
## [0.2.7] - 2025-05-04
|
29
|
+
|
30
|
+
- Always-on pagination: Results from `balance_by_type` are now always paginated for performance (default: 20 per page, page 1). There is no option to disable pagination.
|
31
|
+
- Added support for `per_page` and `page` options to control result size and page.
|
32
|
+
- Added support for `expires_in` option to override the default cache expiration (default: 10 minutes) per call.
|
33
|
+
- Cache adapter is now a first-class, configurable component (`TypeBalancer::Rails.cache_adapter`).
|
34
|
+
- Improved documentation and architecture overview to reflect new pagination and caching behavior.
|
35
|
+
- RuboCop and test stability improvements.
|
2
36
|
|
3
37
|
## [0.2.6] - 2025-05-01
|
4
38
|
|
data/README.md
CHANGED
@@ -26,6 +26,38 @@ Or install it yourself as:
|
|
26
26
|
$ gem install type_balancer_rails
|
27
27
|
```
|
28
28
|
|
29
|
+
## Caching and Performance
|
30
|
+
|
31
|
+
Type balancing can be computationally expensive, especially on large datasets. To ensure efficient performance, **TypeBalancer Rails automatically caches the balanced ID list for each query**. This means:
|
32
|
+
- The balancing algorithm only runs when needed (on cache miss or reset).
|
33
|
+
- Subsequent requests for the same query use the cached result, reducing database and CPU load.
|
34
|
+
- Caching is essential for production use. Disabling or misconfiguring cache may result in slow queries.
|
35
|
+
|
36
|
+
**Adjust cache settings thoughtfully:**
|
37
|
+
- Shorter expiries mean fresher data but more frequent recalculation.
|
38
|
+
- Longer expiries improve performance but may serve stale results if your data changes often.
|
39
|
+
|
40
|
+
## Configuration
|
41
|
+
|
42
|
+
To customize caching and performance, use the Rails-style configuration block in an initializer (e.g., `config/initializers/type_balancer.rb`):
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
TypeBalancer::Rails.configure do |config|
|
46
|
+
# Use the default cache adapter (backed by Rails.cache)
|
47
|
+
config.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
|
48
|
+
|
49
|
+
# Set the global cache expiry (in seconds)
|
50
|
+
# Default is 600 (10 minutes). Adjust as needed for your app's freshness/performance needs.
|
51
|
+
config.cache_expiry_seconds = 600
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
> **Note:** You can also set these options directly
|
56
|
+
> ```ruby
|
57
|
+
> TypeBalancer::Rails.cache_adapter = TypeBalancer::Rails::CacheAdapter.new
|
58
|
+
> TypeBalancer::Rails.cache_expiry_seconds = 600
|
59
|
+
> ```
|
60
|
+
|
29
61
|
## Usage
|
30
62
|
|
31
63
|
To balance records by a given type field, use the following syntax:
|
@@ -82,6 +114,82 @@ The `balance_by_type` method preserves the ActiveRecord query interface:
|
|
82
114
|
.per(20)
|
83
115
|
```
|
84
116
|
|
117
|
+
### Pagination and Caching (Always Enabled)
|
118
|
+
|
119
|
+
Results from `balance_by_type` are **always paginated** for performance reasons. By default, only the first 20 balanced records are returned. You can control the page size and which page is returned using the `per_page` and `page` options:
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
# Get the first 20 balanced records (default)
|
123
|
+
@posts = Post.all.balance_by_type
|
124
|
+
|
125
|
+
# Get the second page of 10 balanced records
|
126
|
+
@posts = Post.all.balance_by_type(type_field: :category, per_page: 10, page: 2)
|
127
|
+
```
|
128
|
+
|
129
|
+
- **Default page size:** 20
|
130
|
+
- **Default page:** 1
|
131
|
+
- **Pagination is required:** There is no option to disable pagination. This is necessary for performance, especially on large datasets.
|
132
|
+
|
133
|
+
#### Cache Expiration
|
134
|
+
|
135
|
+
Balanced results are cached by default for 10 minutes to improve performance and reduce redundant calculations. You can override the cache expiration for a specific call by passing the `expires_in` option:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
# Cache the balanced results for 1 hour instead of 10 minutes
|
139
|
+
@posts = Post.all.balance_by_type(type_field: :category, expires_in: 1.hour)
|
140
|
+
```
|
141
|
+
|
142
|
+
- **Default cache expiration:** 10 minutes
|
143
|
+
- **Custom cache expiration:** Pass `expires_in: ...` (e.g., `expires_in: 1.hour`)
|
144
|
+
|
145
|
+
> **Note:** If you need to retrieve all balanced records, you must manually iterate through all pages.
|
146
|
+
|
147
|
+
### Per-request Cache Control
|
148
|
+
|
149
|
+
You can override cache behavior for a single call to `balance_by_type`:
|
150
|
+
|
151
|
+
- **Custom Expiry:**
|
152
|
+
```ruby
|
153
|
+
# Cache the balanced results for 1 hour for this request only
|
154
|
+
@posts = Post.all.balance_by_type(type_field: :category, expires_in: 1.hour)
|
155
|
+
```
|
156
|
+
- **Force Cache Reset:**
|
157
|
+
```ruby
|
158
|
+
# Force recalculation and cache update for this request
|
159
|
+
@posts = Post.all.balance_by_type(type_field: :category, cache_reset: true)
|
160
|
+
```
|
161
|
+
|
162
|
+
#### Controller Example
|
163
|
+
|
164
|
+
You can pass these options from controller params:
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
class PostsController < ApplicationController
|
168
|
+
def index
|
169
|
+
@posts = Post.all.balance_by_type(
|
170
|
+
type_field: params[:type_field],
|
171
|
+
expires_in: params[:expires_in],
|
172
|
+
cache_reset: params[:cache_reset].present?
|
173
|
+
)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
```
|
177
|
+
|
178
|
+
## Cache Management and Isolation
|
179
|
+
|
180
|
+
- **Cache keys are unique per model and type field.**
|
181
|
+
- There is no cross-contamination between different models or type fields.
|
182
|
+
- If you use multiple type fields or models, each will have its own cache entry.
|
183
|
+
- To avoid stale data, clear the cache after bulk updates or schema changes using `TypeBalancer::Rails.clear_cache!`.
|
184
|
+
|
185
|
+
## Upgrade Notes
|
186
|
+
|
187
|
+
- `balance_by_type` now supports per-request `expires_in` and `cache_reset` options.
|
188
|
+
- Global cache expiry is configurable via `TypeBalancer::Rails.cache_expiry_seconds`.
|
189
|
+
- You can clear all cached results with `TypeBalancer::Rails.clear_cache!`.
|
190
|
+
- Caching is always enabled and required for performance.
|
191
|
+
- Pagination is always enabled; you must page through results if you want all records.
|
192
|
+
|
85
193
|
## Planned Enhancements
|
86
194
|
|
87
195
|
- Support for passing a symbol directly to `balance_by_type`, e.g., `balance_by_type(:media_type)`, for more ergonomic usage. This is planned for a future version.
|
@@ -0,0 +1,43 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# /Users/carl/gems/type_balancer-rails/benchmarks/collection_methods_benchmark.rb
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'type_balancer'
|
6
|
+
require 'active_record'
|
7
|
+
require 'benchmark'
|
8
|
+
require_relative '../lib/type_balancer/rails'
|
9
|
+
|
10
|
+
# Setup in-memory SQLite DB and model
|
11
|
+
db_path = ':memory:'
|
12
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: db_path)
|
13
|
+
|
14
|
+
# Define a simple model for benchmarking
|
15
|
+
class Item < ActiveRecord::Base; end
|
16
|
+
|
17
|
+
# Create table and seed data
|
18
|
+
ActiveRecord::Schema.define do
|
19
|
+
create_table :items, force: true do |t|
|
20
|
+
t.string :media_type
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Seed with N records, distributed across types
|
25
|
+
N = 20_000
|
26
|
+
types = ['video', 'article', 'podcast']
|
27
|
+
puts "Seeding #{N} records..."
|
28
|
+
N.times do |i|
|
29
|
+
Item.create!(media_type: types[i % types.size])
|
30
|
+
end
|
31
|
+
puts 'Seeding complete.'
|
32
|
+
|
33
|
+
# Extend the relation with CollectionMethods
|
34
|
+
relation = Item.all.extending(TypeBalancer::Rails::CollectionMethods)
|
35
|
+
|
36
|
+
# Benchmark balance_by_type
|
37
|
+
puts "\nBenchmarking balance_by_type on #{N} records..."
|
38
|
+
Benchmark.bm(20) do |x|
|
39
|
+
x.report('balance_by_type:') do
|
40
|
+
result = relation.balance_by_type(type_field: :media_type)
|
41
|
+
result.to_a
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# TypeBalancer Rails Gem: Architecture Overview
|
2
|
+
|
3
|
+
## 1. Project Summary
|
4
|
+
|
5
|
+
TypeBalancer Rails is a Ruby gem designed to provide advanced balancing and ordering capabilities for ActiveRecord collections in Rails applications. Its primary feature is the ability to balance records by a specified type, ensuring even distribution and flexible pagination of heterogeneous data sets. The gem is intended for use in Rails projects that require sophisticated content or resource balancing, such as feeds, dashboards, or content aggregators.
|
6
|
+
|
7
|
+
---
|
8
|
+
|
9
|
+
## 2. Architecture & Dependencies
|
10
|
+
|
11
|
+
### High-Level Architecture
|
12
|
+
- **Core Module:** The gem's core logic is implemented under `lib/type_balancer/rails/`, with extensions for ActiveRecord and integration with Rails via a Railtie.
|
13
|
+
- **ActiveRecord Integration:** The gem extends ActiveRecord models and relations to provide the `balance_by_type` method, which can be called on any model or relation.
|
14
|
+
- **Rails Integration:** A Railtie ensures the gem is loaded and configured automatically in Rails environments.
|
15
|
+
- **Generators:** The gem provides a Rails generator for easy installation and configuration.
|
16
|
+
|
17
|
+
### Key Dependencies
|
18
|
+
- **active_support**: Used for concerns, core extensions, and Rails integration.
|
19
|
+
- **active_record**: The gem extends ActiveRecord models and relations.
|
20
|
+
- **type_balancer**: The core balancing logic is delegated to the `type_balancer` gem (see below).
|
21
|
+
- **Rails (optional)**: For automatic integration via Railtie and generator support.
|
22
|
+
|
23
|
+
### About the TypeBalancer Gem
|
24
|
+
|
25
|
+
TypeBalancer is a Ruby gem that provides advanced algorithms for distributing items of different types evenly across a sequence. Its primary use case is to ensure that, in collections where certain types (e.g., articles, images, videos) are overrepresented, the output is balanced so that all types are fairly and optimally spaced. This is especially useful for content feeds, e-commerce listings, news aggregators, and recommendation systems.
|
26
|
+
|
27
|
+
**Key Features:**
|
28
|
+
- Balances collections by type, ensuring optimal spacing and respecting type ratios.
|
29
|
+
- Uses a sophisticated sliding window strategy by default, with support for custom window sizes and type orderings.
|
30
|
+
- Extensible strategy system for future balancing algorithms.
|
31
|
+
- Thread-safe, memory-efficient, and suitable for real-time processing of collections up to 10,000 items.
|
32
|
+
- No external dependencies and high performance across Ruby versions.
|
33
|
+
|
34
|
+
**Core API:**
|
35
|
+
- `TypeBalancer.balance(items, type_field: :type, strategy: :sliding_window, window_size: 10, type_order: [...])`
|
36
|
+
Balances an array of items by the specified type field, using the chosen strategy and options.
|
37
|
+
- `TypeBalancer.calculate_positions(total_count:, ratio:, available_items: [...])`
|
38
|
+
Calculates optimal positions for a given type or subset within a sequence.
|
39
|
+
|
40
|
+
**Integration with Rails:**
|
41
|
+
- The TypeBalancer Rails gem acts as a façade and adapter, exposing TypeBalancer's balancing logic as an easy-to-use method (`balance_by_type`) on ActiveRecord relations and models.
|
42
|
+
- This allows Rails developers to leverage advanced balancing in queries and collections with minimal setup.
|
43
|
+
|
44
|
+
---
|
45
|
+
|
46
|
+
## 3. Class & Module Documentation
|
47
|
+
|
48
|
+
### `TypeBalancer::Rails::CollectionMethods`
|
49
|
+
- **Location:** `lib/type_balancer/rails/collection_methods.rb`
|
50
|
+
- **Purpose:**
|
51
|
+
- Provides the `balance_by_type` method for ActiveRecord::Relation, enabling balanced selection and ordering of records by a type field.
|
52
|
+
- **Always paginates results** for performance. Default: 20 per page, page 1. There is no option to disable pagination.
|
53
|
+
- Supports `per_page` and `page` options to control result size and page.
|
54
|
+
- **Results are cached** for 10 minutes by default, but you can override this per call with the `expires_in` option (e.g., `expires_in: 1.hour`).
|
55
|
+
- Handles result ordering and cache key generation.
|
56
|
+
- **Dependencies:**
|
57
|
+
- Depends on `TypeBalancer.balance` for core balancing logic.
|
58
|
+
- Expects to be included in ActiveRecord::Relation.
|
59
|
+
- **Patterns:**
|
60
|
+
- Adapter/Extension pattern for ActiveRecord::Relation.
|
61
|
+
|
62
|
+
### `TypeBalancer::Rails::ActiveRecordExtension`
|
63
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/active_record_extension.rb`
|
64
|
+
- **Purpose:**
|
65
|
+
- Provides a concern to extend ActiveRecord models with type balancing configuration and class-level `balance_by_type` method.
|
66
|
+
- Ensures `CollectionMethods` is included in ActiveRecord::Relation.
|
67
|
+
- **Dependencies:**
|
68
|
+
- `ActiveSupport::Concern`, `ActiveRecord::Relation`, `TypeBalancer::Rails::CollectionMethods`.
|
69
|
+
- **Patterns:**
|
70
|
+
- Rails Concern, Extension.
|
71
|
+
|
72
|
+
### `TypeBalancer::Rails::Railtie`
|
73
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/railtie.rb`
|
74
|
+
- **Purpose:**
|
75
|
+
- Integrates the gem with Rails, ensuring the ActiveRecord extension is loaded automatically.
|
76
|
+
- **Dependencies:**
|
77
|
+
- `Rails::Railtie`, `ActiveSupport.on_load(:active_record)`.
|
78
|
+
- **Patterns:**
|
79
|
+
- Railtie for Rails integration.
|
80
|
+
|
81
|
+
### `TypeBalancer::Rails::VERSION`
|
82
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails/version.rb`
|
83
|
+
- **Purpose:**
|
84
|
+
- Defines the gem's version constant.
|
85
|
+
- **Dependencies:** None.
|
86
|
+
|
87
|
+
### `TypeBalancer::Rails` (Module)
|
88
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer/rails.rb`
|
89
|
+
- **Purpose:**
|
90
|
+
- Loads and wires up all Rails-specific extensions and dependencies for the gem.
|
91
|
+
- **Exposes a configurable cache adapter** (`TypeBalancer::Rails.cache_adapter`) used for caching balanced results.
|
92
|
+
- **Dependencies:**
|
93
|
+
- `active_support`, `active_record`, `TypeBalancer::Rails::ActiveRecordExtension`, `TypeBalancer::Rails::CollectionMethods`.
|
94
|
+
|
95
|
+
### Main Entry File
|
96
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/type_balancer_rails.rb`
|
97
|
+
- **Purpose:**
|
98
|
+
- Loads all required dependencies and sets up the gem for use in a Rails environment.
|
99
|
+
- Loads the Railtie if Rails is present.
|
100
|
+
|
101
|
+
### Generator: `TypeBalancer::Generators::InstallGenerator`
|
102
|
+
- **Location:** `/Users/carl/gems/type_balancer-rails/lib/generators/type_balancer/install/install_generator.rb`
|
103
|
+
- **Purpose:**
|
104
|
+
- Provides a Rails generator for installing TypeBalancer configuration into a Rails app.
|
105
|
+
- **Dependencies:**
|
106
|
+
- Rails generator framework.
|
107
|
+
- **Patterns:**
|
108
|
+
- Generator pattern for Rails setup.
|
109
|
+
|
110
|
+
---
|
111
|
+
|
112
|
+
## 4. Testing Strategy
|
113
|
+
|
114
|
+
- **Unit Tests:**
|
115
|
+
- Located in `/Users/carl/gems/type_balancer-rails/spec/`.
|
116
|
+
- Organized by feature/module (e.g., `spec/type_balancer/rails/collection_methods_spec.rb`).
|
117
|
+
- Do **not** use a database; all database and dependency interactions are strictly mocked or stubbed.
|
118
|
+
- Shared contexts and helpers are provided in `spec/support/` (e.g., `test_helpers.rb`, `test_fixtures.rb`).
|
119
|
+
- Test models for mocking are in `spec/support/models/`.
|
120
|
+
|
121
|
+
- **Integration Tests:**
|
122
|
+
- Located in the example Rails app under `/Users/carl/gems/type_balancer-rails/example/`.
|
123
|
+
- Example app contains its own `spec/` directory with feature, controller, and model specs.
|
124
|
+
- Integration tests use a real database and Rails stack to verify end-to-end behavior.
|
125
|
+
- Example app includes its own Gemfile and configuration for isolated testing.
|
126
|
+
|
127
|
+
- **Testing Practices:**
|
128
|
+
- Unit tests focus on class responsibilities and use mocking for all external dependencies.
|
129
|
+
- Integration tests are only created in the example app and are not run as part of the main gem's unit test suite.
|
130
|
+
- No direct tests of private methods; only public interfaces are tested.
|
131
|
+
- RSpec is used as the test framework throughout.
|
132
|
+
- RuboCop is used for code style enforcement (`.rubocop.yml` present in both root and example app).
|
133
|
+
- CI and test runner setup is inferred from the presence of `.rspec`, `.rspec_status`, and Rakefile.
|
134
|
+
|
135
|
+
- **Interesting/Unique Practices:**
|
136
|
+
- Strict separation of unit and integration tests, with clear boundaries and no database usage in unit tests.
|
137
|
+
- Use of shared contexts and helpers to DRY up test setup.
|
138
|
+
- Example app serves as a living integration testbed for real-world Rails usage.
|
139
|
+
|
140
|
+
---
|
141
|
+
|
142
|
+
## 5. Additional Notes
|
143
|
+
- All file paths in this documentation are absolute, per project standards.
|
144
|
+
- The gem follows SOLID principles and Rails best practices, with minimal monkey-patching and clear extension points.
|
145
|
+
- **Pagination and caching are always enabled for performance reasons.**
|
146
|
+
- Default: 20 per page, page 1, 10 minute cache expiration.
|
147
|
+
- Use `per_page`, `page`, and `expires_in` options to customize.
|
148
|
+
- For more details, see the README and inline code comments.
|
data/example/Gemfile.lock
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
type_balancer_rails (0.2.
|
4
|
+
type_balancer_rails (0.2.8)
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
7
|
-
type_balancer (
|
7
|
+
type_balancer (>= 0.2.1)
|
8
8
|
|
9
9
|
GEM
|
10
10
|
remote: https://rubygems.org/
|
@@ -381,7 +381,7 @@ GEM
|
|
381
381
|
turbo-rails (2.0.13)
|
382
382
|
actionpack (>= 7.1.0)
|
383
383
|
railties (>= 7.1.0)
|
384
|
-
type_balancer (0.2.
|
384
|
+
type_balancer (0.2.1)
|
385
385
|
tzinfo (2.0.6)
|
386
386
|
concurrent-ruby (~> 1.0)
|
387
387
|
unicode-display_width (3.1.4)
|
@@ -20,7 +20,7 @@ Rails.application.configure do
|
|
20
20
|
|
21
21
|
# Show full error reports.
|
22
22
|
config.consider_all_requests_local = true
|
23
|
-
config.cache_store = :
|
23
|
+
config.cache_store = :memory_store
|
24
24
|
|
25
25
|
# Render exception templates for rescuable exceptions and raise for other exceptions.
|
26
26
|
config.action_dispatch.show_exceptions = :rescuable
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'rails_helper'
|
2
|
+
|
3
|
+
RSpec.describe PostsController, type: :controller do
|
4
|
+
fixtures :posts
|
5
|
+
|
6
|
+
before do
|
7
|
+
Rails.cache.clear
|
8
|
+
allow(TypeBalancer).to receive(:balance).and_call_original
|
9
|
+
end
|
10
|
+
|
11
|
+
describe 'GET #index' do
|
12
|
+
it 'caches the balanced ID list and reuses it' do
|
13
|
+
# First call: should invoke the balancer
|
14
|
+
get :index
|
15
|
+
expect(TypeBalancer).to have_received(:balance).once
|
16
|
+
first_result = assigns(:posts).map(&:id)
|
17
|
+
|
18
|
+
# Second call: should use the cache, not call balancer again
|
19
|
+
get :index
|
20
|
+
expect(TypeBalancer).to have_received(:balance).once
|
21
|
+
second_result = assigns(:posts).map(&:id)
|
22
|
+
expect(second_result).to eq(first_result)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'resets the cache when requested via controller param' do
|
26
|
+
# Prime the cache
|
27
|
+
get :index
|
28
|
+
expect(TypeBalancer).to have_received(:balance).once
|
29
|
+
first_result = assigns(:posts).map(&:id)
|
30
|
+
|
31
|
+
# Simulate cache reset by passing param (assuming controller supports it)
|
32
|
+
allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
|
33
|
+
get :index, params: { cache_reset: true }
|
34
|
+
expect(TypeBalancer).to have_received(:balance).once
|
35
|
+
second_result = assigns(:posts).map(&:id)
|
36
|
+
expect(second_result).to eq(first_result) # Should still be balanced, but balancer called again
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'isolates cache by type field' do
|
40
|
+
get :index, params: { type_field: 'media_type' }
|
41
|
+
expect(TypeBalancer).to have_received(:balance).once
|
42
|
+
allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
|
43
|
+
get :index, params: { type_field: 'other_type' }
|
44
|
+
expect(TypeBalancer).to have_received(:balance).once
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'respects per-request expires_in option' do
|
48
|
+
get :index, params: { expires_in: 1 }
|
49
|
+
expect(TypeBalancer).to have_received(:balance).once
|
50
|
+
sleep 2
|
51
|
+
allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
|
52
|
+
get :index, params: { expires_in: 1 }
|
53
|
+
expect(TypeBalancer).to have_received(:balance).once
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'expires cache and calls balancer again after expiry' do
|
57
|
+
get :index, params: { expires_in: 1 }
|
58
|
+
expect(TypeBalancer).to have_received(:balance).once
|
59
|
+
sleep 2
|
60
|
+
allow(TypeBalancer).to receive(:balance).and_call_original # reset spy
|
61
|
+
get :index, params: { expires_in: 1 }
|
62
|
+
expect(TypeBalancer).to have_received(:balance).once
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -9,7 +9,7 @@ RSpec.feature 'Contents balancing', type: :feature do
|
|
9
9
|
# Collect the categories from the second column of the table rows
|
10
10
|
categories = page.all('table tbody tr').map { |row| row.all('td')[1]&.text }.compact
|
11
11
|
|
12
|
-
expect(categories.count).to eq(
|
12
|
+
expect(categories.count).to eq(20)
|
13
13
|
# There should be a mix of categories, not just a long run of one category
|
14
14
|
expect(categories.uniq.sort).to eq(%w[blog news tutorial])
|
15
15
|
# Check that the first 10 are not all the same (skewed fixture would be all news)
|
@@ -19,6 +19,7 @@ RSpec.describe Content, type: :model do
|
|
19
19
|
|
20
20
|
describe 'type balancing with real records' do
|
21
21
|
before do
|
22
|
+
TypeBalancer::Rails.clear_cache!
|
22
23
|
described_class.delete_all
|
23
24
|
@content1 = described_class.create!(title: 'Content 1', content_type: 'blog')
|
24
25
|
@content2 = described_class.create!(title: 'Content 2', content_type: 'news')
|
@@ -16,6 +16,7 @@ RSpec.describe Post, type: :model do
|
|
16
16
|
let(:another_video_post) { described_class.create!(title: 'Post 3', media_type: 'video') }
|
17
17
|
|
18
18
|
before do
|
19
|
+
TypeBalancer::Rails.clear_cache!
|
19
20
|
described_class.delete_all
|
20
21
|
video_post; article_post; another_video_post # Create the records
|
21
22
|
end
|
Binary file
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module TypeBalancer
|
2
|
+
module Rails
|
3
|
+
class CacheAdapter
|
4
|
+
def initialize
|
5
|
+
@memory_cache = {}
|
6
|
+
end
|
7
|
+
|
8
|
+
def fetch(key, options = {}, &block)
|
9
|
+
if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
|
10
|
+
::Rails.cache.fetch(key, options, &block)
|
11
|
+
else
|
12
|
+
@memory_cache[key] ||= block.call
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def write(key, value, options = {})
|
17
|
+
if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
|
18
|
+
::Rails.cache.write(key, value, options)
|
19
|
+
else
|
20
|
+
@memory_cache[key] = value
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def delete(key)
|
25
|
+
if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
|
26
|
+
::Rails.cache.delete(key)
|
27
|
+
else
|
28
|
+
@memory_cache.delete(key)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def clear_cache!
|
33
|
+
::Rails.cache.clear if defined?(::Rails) && ::Rails.respond_to?(:cache) && ::Rails.cache
|
34
|
+
@memory_cache.clear
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -6,52 +6,60 @@ module TypeBalancer
|
|
6
6
|
# Provides collection methods for balancing by type
|
7
7
|
# These methods are extended onto ActiveRecord::Relation
|
8
8
|
module CollectionMethods
|
9
|
-
|
10
|
-
records = to_a
|
11
|
-
return empty_relation if records.empty?
|
12
|
-
|
13
|
-
type_field = fetch_type_field(options).to_sym
|
14
|
-
type_counts = records.group_by { |r| r.send(type_field).to_s }.transform_values(&:count)
|
15
|
-
type_order = compute_type_order(type_counts)
|
16
|
-
items = build_items(records, type_field)
|
9
|
+
require 'digest/md5'
|
17
10
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
11
|
+
def balance_by_type(options = {})
|
12
|
+
type_field, offset, per_page = pagination_params(options)
|
13
|
+
cache_key = build_cache_key(type_field)
|
14
|
+
expires_in = options[:expires_in] || ::TypeBalancer::Rails.cache_expiry_seconds
|
15
|
+
cache_reset = options[:cache_reset]
|
16
|
+
if cache_reset
|
17
|
+
ids = compute_ids(type_field)
|
18
|
+
TypeBalancer::Rails.cache_adapter.write(cache_key, ids, expires_in: expires_in)
|
19
|
+
else
|
20
|
+
ids = TypeBalancer::Rails.cache_adapter.fetch(cache_key, expires_in: expires_in) do
|
21
|
+
compute_ids(type_field)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
page_ids = ids[offset, per_page] || []
|
25
|
+
return empty_relation if page_ids.empty?
|
27
26
|
|
28
|
-
|
27
|
+
order_by_ids(page_ids)
|
29
28
|
end
|
30
29
|
|
31
30
|
private
|
32
31
|
|
33
|
-
def
|
34
|
-
|
32
|
+
def pagination_params(options)
|
33
|
+
type_field = fetch_type_field(options).to_sym
|
34
|
+
page = (options[:page] || 1).to_i
|
35
|
+
per_page = (options[:per_page] || 20).to_i
|
36
|
+
offset = (page - 1) * per_page
|
37
|
+
[type_field, offset, per_page]
|
38
|
+
end
|
35
39
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
+
def build_cache_key(type_field)
|
41
|
+
[
|
42
|
+
'type_balancer',
|
43
|
+
klass.name,
|
44
|
+
type_field,
|
45
|
+
Digest::MD5.hexdigest(to_sql)
|
46
|
+
].join(':')
|
40
47
|
end
|
41
48
|
|
42
|
-
def
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
49
|
+
def compute_ids(type_field)
|
50
|
+
records = select(:id, type_field)
|
51
|
+
items = records.map { |r| { id: r.id, type_field => r.public_send(type_field) } }
|
52
|
+
type_counts = items.group_by { |h| h[type_field] }.transform_values(&:size)
|
53
|
+
order = compute_type_order(type_counts)
|
54
|
+
balanced = TypeBalancer.balance(items, type_field: type_field, type_order: order)
|
55
|
+
balanced ? balanced.flatten(1).map { |h| h[:id] } : []
|
56
|
+
rescue TypeBalancer::EmptyCollectionError
|
57
|
+
[]
|
58
|
+
end
|
48
59
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
relation = relation.order(Arel.sql(case_sql))
|
53
|
-
end
|
54
|
-
relation
|
60
|
+
def order_by_ids(ids)
|
61
|
+
case_sql = "CASE id #{ids.each_with_index.map { |id, idx| "WHEN #{id} THEN #{idx}" }.join(' ')} END"
|
62
|
+
klass.where(id: ids).order(Arel.sql(case_sql))
|
55
63
|
end
|
56
64
|
|
57
65
|
def empty_relation
|
@@ -70,28 +78,6 @@ module TypeBalancer
|
|
70
78
|
def compute_type_order(type_counts)
|
71
79
|
type_counts.sort_by { |_, count| count }.map(&:first)
|
72
80
|
end
|
73
|
-
|
74
|
-
def build_items(records, type_field)
|
75
|
-
records.map do |record|
|
76
|
-
{ id: record.id, type_field => record.send(type_field).to_s }
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
def logger?
|
81
|
-
defined?(::Rails) && ::Rails.logger
|
82
|
-
end
|
83
|
-
|
84
|
-
def balance_results_lines(balanced, _type_field)
|
85
|
-
if balanced.nil?
|
86
|
-
['Balanced result is nil!']
|
87
|
-
else
|
88
|
-
[
|
89
|
-
"First 10 balanced types: \#{balanced.first(10).map { |h| h[type_field] }.inspect}",
|
90
|
-
"Unique types in first 10: \#{balanced.first(10).map { |h| h[type_field] }.uniq.inspect}",
|
91
|
-
"Total balanced items: \#{balanced.size}"
|
92
|
-
]
|
93
|
-
end
|
94
|
-
end
|
95
81
|
end
|
96
82
|
end
|
97
83
|
end
|
data/lib/type_balancer/rails.rb
CHANGED
@@ -6,8 +6,23 @@ require 'active_record'
|
|
6
6
|
|
7
7
|
require_relative 'rails/active_record_extension'
|
8
8
|
require_relative 'rails/collection_methods'
|
9
|
+
require_relative 'rails/cache_adapter'
|
9
10
|
|
10
11
|
module TypeBalancer
|
11
12
|
module Rails
|
13
|
+
class << self
|
14
|
+
attr_accessor :cache_adapter, :cache_expiry_seconds
|
15
|
+
|
16
|
+
def clear_cache!
|
17
|
+
cache_adapter&.clear_cache!
|
18
|
+
end
|
19
|
+
|
20
|
+
# Rails-style configuration block
|
21
|
+
def configure
|
22
|
+
yield self if block_given?
|
23
|
+
end
|
24
|
+
end
|
25
|
+
self.cache_adapter ||= TypeBalancer::Rails::CacheAdapter.new
|
26
|
+
self.cache_expiry_seconds ||= 600 # 10 minutes default
|
12
27
|
end
|
13
28
|
end
|
data/type_balancer_rails.gemspec
CHANGED
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
|
|
32
32
|
# Runtime dependencies
|
33
33
|
spec.add_dependency 'activerecord', '>= 7.0', '< 9.0'
|
34
34
|
spec.add_dependency 'activesupport', '>= 7.0', '< 9.0'
|
35
|
-
spec.add_dependency 'type_balancer', '
|
35
|
+
spec.add_dependency 'type_balancer', '>= 0.2.1'
|
36
36
|
|
37
37
|
# For more information and examples about making a new gem, check out our
|
38
38
|
# guide at: https://bundler.io/guides/creating_gem.html
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: type_balancer_rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Carl Smith
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-05-
|
11
|
+
date: 2025-05-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -54,14 +54,14 @@ dependencies:
|
|
54
54
|
name: type_balancer
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
|
-
- - "
|
57
|
+
- - ">="
|
58
58
|
- !ruby/object:Gem::Version
|
59
59
|
version: 0.2.1
|
60
60
|
type: :runtime
|
61
61
|
prerelease: false
|
62
62
|
version_requirements: !ruby/object:Gem::Requirement
|
63
63
|
requirements:
|
64
|
-
- - "
|
64
|
+
- - ">="
|
65
65
|
- !ruby/object:Gem::Version
|
66
66
|
version: 0.2.1
|
67
67
|
description: Provides Rails integration for the type_balancer gem
|
@@ -78,6 +78,8 @@ files:
|
|
78
78
|
- LICENSE.txt
|
79
79
|
- README.md
|
80
80
|
- Rakefile
|
81
|
+
- benchmarks/collection_methods_benchmark.rb
|
82
|
+
- docs/architecture_overview.md
|
81
83
|
- example/.dockerignore
|
82
84
|
- example/.rspec
|
83
85
|
- example/.rubocop.yml
|
@@ -145,6 +147,7 @@ files:
|
|
145
147
|
- example/lib/tasks/dev_fixtures.rake
|
146
148
|
- example/script/.keep
|
147
149
|
- example/spec/controllers/contents_controller_spec.rb
|
150
|
+
- example/spec/controllers/posts_controller_caching_spec.rb
|
148
151
|
- example/spec/controllers/posts_controller_spec.rb
|
149
152
|
- example/spec/features/contents_balancing_spec.rb
|
150
153
|
- example/spec/features/posts_balancing_spec.rb
|
@@ -164,6 +167,7 @@ files:
|
|
164
167
|
- lib/generators/type_balancer/install/templates/type_balancer.rb.erb
|
165
168
|
- lib/type_balancer/rails.rb
|
166
169
|
- lib/type_balancer/rails/active_record_extension.rb
|
170
|
+
- lib/type_balancer/rails/cache_adapter.rb
|
167
171
|
- lib/type_balancer/rails/collection_methods.rb
|
168
172
|
- lib/type_balancer/rails/railtie.rb
|
169
173
|
- lib/type_balancer/rails/version.rb
|