weathercock 1.0.0 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d2874d2b19beabee7ca9a2d408ce855a1f30560fc44b1f4b9e2efc77b626688
4
- data.tar.gz: 1f95984e5dd96a65a07069e0bea20b8f0ad14bcd23770bc8b05b4544a53d12f1
3
+ metadata.gz: cc7554f2d86d4cd3499ea0aa89d77bc327e9667b16257b234f00391d82118f8b
4
+ data.tar.gz: 7687c05fe5724b8ec134bfbaf9fc4689b799415f368627a85de790b8ddcd7073
5
5
  SHA512:
6
- metadata.gz: cb6fb7895358325549f117b807b75889a0a49ca839deadd4347b4d137c156cd7dac3acf4303b3845a5b11f02bcdc402eb95b8d18feb5fe80b7973676c5cb1939
7
- data.tar.gz: 4d347a60ccb2ff6d823bb1586d9ef683702d72a7973ea9a3825f1a3c5f7eeb72f0e110cca5f14f6bf098a5f9076c6d60b6b91ad1b088d6ab85b115294efeda82
6
+ metadata.gz: 9bb7bf12377be72a41e51a5de234475e2cbab1d3a8333b47865da959053d482b5bcb762d4c13fa00ed142a95bf64c531e2b04a22a608969440a7feb45dd881dc
7
+ data.tar.gz: 90e5eafb164fe1fdc2b11167b28a9f9a6fda24dc1be2ed98e987505b05bb4aabe7f8bb7b66b9141e658bdba0e70cc36a808be3a557a42ff19c944ef115d020ff
data/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## [1.0.0] - 2026-04-15
3
+ ## v1.1.0 - 2026-04-17
4
+
5
+ ### Added
6
+
7
+ - `#rank` returns the 1-indexed ranking position of an instance; returns `nil` if unranked
8
+ - `#remove_hits` removes all recorded hits for an instance across all keys
9
+
10
+ ### Changed
11
+
12
+ - Bucket count unified to 90 across all time granularities; data is retained longer
13
+ - `hit_counts` now uses a single ZMSCORE call instead of N ZSCORE calls
14
+
15
+ ## v1.0.0 - 2026-04-15
4
16
 
5
17
  ### Added
6
18
 
@@ -10,6 +22,6 @@
10
22
 
11
23
  - `require "weathercock"` now loads `Weathercock::Scorable` automatically
12
24
 
13
- ## [0.1.0] - 2026-04-15
25
+ ## v0.1.0 - 2026-04-15
14
26
 
15
27
  Initial release.
data/README.md CHANGED
@@ -53,18 +53,21 @@ myapp:article:views:2026-04 # monthly (TTL: 3 years)
53
53
 
54
54
  ```ruby
55
55
  # All-time ranking
56
- Article.top(:views)
56
+ Article.top(:views, limit: 10)
57
57
  # => ["42", "7", "133", ...]
58
58
 
59
59
  # Top article IDs by views over the last 7 days
60
- Article.top(:views, days: 7)
60
+ Article.top(:views, days: 7, limit: 10)
61
61
 
62
62
  # Using hours or months
63
- Article.top(:views, hours: 24)
64
- Article.top(:views, months: 3)
63
+ Article.top(:views, hours: 24, limit: 10)
64
+ Article.top(:views, months: 3, limit: 10)
65
65
 
66
66
  # Exponential time decay (recent hits weighted higher)
67
- Article.top(:views, days: 7, decay_factor: 0.9)
67
+ Article.top(:views, days: 7, decay_factor: 0.9, limit: 10)
68
+
69
+ # Retrieve all results
70
+ Article.top(:views, limit: nil)
68
71
  ```
69
72
 
70
73
  ### Counting
@@ -85,6 +88,26 @@ Article.hit_counts(:views, ids: [1, 2, 3], days: 7)
85
88
  # => {"1" => 42, "2" => 15, "3" => 7}
86
89
  ```
87
90
 
91
+ ### Ranking position
92
+
93
+ Get the 1-indexed rank of a specific instance:
94
+
95
+ ```ruby
96
+ article.rank(:views) # all-time
97
+ article.rank(:views, days: 7)
98
+ # => 1
99
+ ```
100
+
101
+ Returns `nil` if the item has no hits.
102
+
103
+ ### Removing hits
104
+
105
+ Remove all recorded hits for an instance across all keys:
106
+
107
+ ```ruby
108
+ article.remove_hits(:views)
109
+ ```
110
+
88
111
  ## Development
89
112
 
90
113
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Weathercock
6
+ class KeyBuilder
7
+ def initialize(namespace:, klass:)
8
+ @namespace = namespace
9
+ @klass_key = klass.name.gsub("::", "_").downcase
10
+ end
11
+
12
+ def base(event)
13
+ "#{@namespace}:#{@klass_key}:#{event}"
14
+ end
15
+
16
+ def total(base)
17
+ "#{base}:total"
18
+ end
19
+
20
+ def bucket(base, type, time)
21
+ case type
22
+ when :hours then "#{base}:#{time.strftime("%Y-%m-%d-%H")}"
23
+ when :days then "#{base}:#{time.strftime("%Y-%m-%d")}"
24
+ when :months then "#{base}:#{time.strftime("%Y-%m")}"
25
+ end
26
+ end
27
+
28
+ def window_keys(base, type, count)
29
+ now = Time.now
30
+ case type
31
+ when :hours
32
+ count.times.map { |i| bucket(base, type, now - (i * 3600)) }
33
+ when :days
34
+ count.times.map { |i| bucket(base, type, now - (i * 86400)) }
35
+ when :months
36
+ d = Date.new(now.year, now.month)
37
+ count.times.map { |i| bucket(base, type, d << i) }
38
+ end
39
+ end
40
+
41
+ def union_dest(base, type, count)
42
+ "#{base}:top:#{type}:#{count}"
43
+ end
44
+ end
45
+ end
@@ -1,102 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
- require_relative "../weathercock"
5
-
6
3
  module Weathercock
7
4
  module Scorable
8
- WEATHERCOCK_BUCKET_TTLS = {
9
- hours: 3 * 24 * 3600,
10
- days: 3 * 30 * 86400,
11
- months: 3 * 12 * 30 * 86400
12
- }.freeze
13
-
14
5
  def self.included(base)
15
6
  base.extend(ClassMethods)
16
7
  end
17
8
 
18
9
  module ClassMethods
19
- def top(event, limit:, decay_factor: nil, **window)
20
- stop = limit ? limit - 1 : -1
21
- return Weathercock.config.redis.call("ZRANGE", "#{weathercock_base_key(event)}:total", 0, stop, "REV") if window.empty?
22
-
23
- dest = weathercock_union(event, window, decay_factor: decay_factor)
24
- Weathercock.config.redis.call("ZRANGE", dest, 0, stop, "REV")
10
+ def weathercock_scorer
11
+ @weathercock_scorer ||= Scorer.new(klass: self)
25
12
  end
26
13
 
27
- def hit_counts(event, ids:, **window)
28
- redis = Weathercock.config.redis
29
- dest = window.empty? ? "#{weathercock_base_key(event)}:total" : weathercock_union(event, window)
30
- ids.to_h { |id| [id.to_s, (redis.call("ZSCORE", dest, id.to_s) || "0").to_i] }
31
- end
32
-
33
- def weathercock_base_key(event)
34
- "#{Weathercock.config.namespace}:#{name.gsub("::", "_").downcase}:#{event}"
35
- end
36
-
37
- private
38
-
39
- def weathercock_union(event, window, decay_factor: nil)
40
- redis = Weathercock.config.redis
41
- base = weathercock_base_key(event)
42
- type, count = window.first
43
- keys = weathercock_window_keys(base, type, count)
44
-
45
- dest = "#{base}:top:#{type}:#{count}"
46
- weights = decay_factor ? count.times.map { |i| (decay_factor**i).round(10) } : nil
47
- zunionstore_args = ["ZUNIONSTORE", dest, keys.size, *keys]
48
- zunionstore_args += ["WEIGHTS", *weights] if weights
49
- redis.call(*zunionstore_args)
50
- redis.call("EXPIRE", dest, 900)
51
- dest
52
- end
53
-
54
- def weathercock_window_keys(base, type, count)
55
- now = Time.now
56
- case type
57
- when :hours
58
- count.times.map { |i| weathercock_bucket_key(base, type, now - (i * 3600)) }
59
- when :days
60
- count.times.map { |i| weathercock_bucket_key(base, type, now - (i * 86400)) }
61
- when :months
62
- d = Date.new(now.year, now.month)
63
- count.times.map { |i| weathercock_bucket_key(base, type, d << i) }
64
- end
14
+ def top(event, limit:, decay_factor: nil, **window)
15
+ weathercock_scorer.top(event, limit: limit, decay_factor: decay_factor, **window)
65
16
  end
66
17
 
67
- def weathercock_bucket_key(base, type, time)
68
- case type
69
- when :hours then "#{base}:#{time.strftime("%Y-%m-%d-%H")}"
70
- when :days then "#{base}:#{time.strftime("%Y-%m-%d")}"
71
- when :months then "#{base}:#{time.strftime("%Y-%m")}"
72
- end
18
+ def hit_counts(event, ids:, **window)
19
+ weathercock_scorer.hit_counts(event, ids: ids, **window)
73
20
  end
74
21
  end
75
22
 
76
23
  def hit(event, increment: 1)
77
- now = Time.now
78
- redis = Weathercock.config.redis
79
- base = self.class.weathercock_base_key(event)
80
-
81
- redis.pipelined do |p|
82
- p.call("ZINCRBY", "#{base}:total", increment, id.to_s)
83
- WEATHERCOCK_BUCKET_TTLS.each do |type, ttl|
84
- key = self.class.send(:weathercock_bucket_key, base, type, now)
85
- p.call("ZINCRBY", key, increment, id.to_s)
86
- p.call("EXPIRE", key, ttl)
87
- end
88
- end
24
+ self.class.weathercock_scorer.hit(id, event, increment: increment)
89
25
  end
90
26
 
91
27
  def hit_count(event, **window)
92
- if window.empty?
93
- score = Weathercock.config.redis.call("ZSCORE", "#{self.class.weathercock_base_key(event)}:total", id.to_s)
94
- return score ? score.to_i : 0
95
- end
28
+ self.class.weathercock_scorer.hit_count(id, event, **window)
29
+ end
30
+
31
+ def rank(event, **window)
32
+ self.class.weathercock_scorer.rank(id, event, **window)
33
+ end
96
34
 
97
- dest = self.class.send(:weathercock_union, event, window)
98
- score = Weathercock.config.redis.call("ZSCORE", dest, id.to_s)
99
- score ? score.to_i : 0
35
+ def remove_hits(event)
36
+ self.class.weathercock_scorer.remove_hits(id, event)
100
37
  end
101
38
  end
102
39
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weathercock
4
+ class Scorer
5
+ BUCKET_COUNT = 90
6
+
7
+ BUCKET_TTLS = {
8
+ hours: BUCKET_COUNT * 3600,
9
+ days: BUCKET_COUNT * 86400,
10
+ months: BUCKET_COUNT * 30 * 86400
11
+ }.freeze
12
+
13
+ def initialize(klass:)
14
+ @redis = Weathercock.config.redis
15
+ @key_builder = KeyBuilder.new(namespace: Weathercock.config.namespace, klass: klass)
16
+ end
17
+
18
+ def hit(id, event, increment: 1)
19
+ now = Time.now
20
+ base = @key_builder.base(event)
21
+
22
+ @redis.pipelined do |p|
23
+ p.call("ZINCRBY", @key_builder.total(base), increment, id.to_s)
24
+ BUCKET_TTLS.each do |type, ttl|
25
+ key = @key_builder.bucket(base, type, now)
26
+ p.call("ZINCRBY", key, increment, id.to_s)
27
+ p.call("EXPIRE", key, ttl)
28
+ end
29
+ end
30
+ end
31
+
32
+ def remove_hits(id, event)
33
+ base = @key_builder.base(event)
34
+
35
+ @redis.pipelined do |p|
36
+ p.call("ZREM", @key_builder.total(base), id.to_s)
37
+ BUCKET_TTLS.each_key do |type|
38
+ @key_builder.window_keys(base, type, BUCKET_COUNT).each do |key|
39
+ p.call("ZREM", key, id.to_s)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def hit_count(id, event, **window)
46
+ base = @key_builder.base(event)
47
+ if window.empty?
48
+ score = @redis.call("ZSCORE", @key_builder.total(base), id.to_s)
49
+ return score ? score.to_i : 0
50
+ end
51
+
52
+ dest = union(event, window)
53
+ score = @redis.call("ZSCORE", dest, id.to_s)
54
+ score ? score.to_i : 0
55
+ end
56
+
57
+ def top(event, limit:, decay_factor: nil, **window)
58
+ base = @key_builder.base(event)
59
+ stop = limit ? limit - 1 : -1
60
+ return @redis.call("ZRANGE", @key_builder.total(base), 0, stop, "REV") if window.empty?
61
+
62
+ dest = union(event, window, decay_factor: decay_factor)
63
+ @redis.call("ZRANGE", dest, 0, stop, "REV")
64
+ end
65
+
66
+ def rank(id, event, **window)
67
+ base = @key_builder.base(event)
68
+ dest = window.empty? ? @key_builder.total(base) : union(event, window)
69
+ r = @redis.call("ZREVRANK", dest, id.to_s)
70
+ r ? r + 1 : nil
71
+ end
72
+
73
+ def hit_counts(event, ids:, **window)
74
+ base = @key_builder.base(event)
75
+ dest = window.empty? ? @key_builder.total(base) : union(event, window)
76
+ scores = @redis.call("ZMSCORE", dest, *ids.map(&:to_s))
77
+ ids.zip(scores).to_h { |id, score| [id.to_s, (score || "0").to_i] }
78
+ end
79
+
80
+ private
81
+
82
+ def union(event, window, decay_factor: nil)
83
+ base = @key_builder.base(event)
84
+ type, count = window.first
85
+ keys = @key_builder.window_keys(base, type, count)
86
+ dest = @key_builder.union_dest(base, type, count)
87
+
88
+ weights = decay_factor ? count.times.map { |i| (decay_factor**i).round(10) } : nil
89
+ zunionstore_args = ["ZUNIONSTORE", dest, keys.size, *keys]
90
+ zunionstore_args += ["WEIGHTS", *weights] if weights
91
+ @redis.call(*zunionstore_args)
92
+ @redis.call("EXPIRE", dest, 900)
93
+ dest
94
+ end
95
+ end
96
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Weathercock
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/weathercock.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "weathercock/version"
4
+ require_relative "weathercock/key_builder"
5
+ require_relative "weathercock/scorer"
4
6
  require_relative "weathercock/scorable"
5
7
 
6
8
  module Weathercock
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: weathercock
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takafumi ONAKA
@@ -51,7 +51,9 @@ files:
51
51
  - Rakefile
52
52
  - compose.yaml
53
53
  - lib/weathercock.rb
54
+ - lib/weathercock/key_builder.rb
54
55
  - lib/weathercock/scorable.rb
56
+ - lib/weathercock/scorer.rb
55
57
  - lib/weathercock/version.rb
56
58
  - sig/weathercock.rbs
57
59
  homepage: https://github.com/onk/weathercock