weathercock 0.1.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: 69167dba72d21575ef52f1c799423a78ff435b8fa42a8ea60eac368a1dbe680b
4
- data.tar.gz: 586f93c2471f63dc178de81ee96c3d39dd205c41ba69984535d2d14012b04f2c
3
+ metadata.gz: cc7554f2d86d4cd3499ea0aa89d77bc327e9667b16257b234f00391d82118f8b
4
+ data.tar.gz: 7687c05fe5724b8ec134bfbaf9fc4689b799415f368627a85de790b8ddcd7073
5
5
  SHA512:
6
- metadata.gz: 362d33770a45bbf057ee4907dd978ce032444e134972f841dc903c52f7df2511aa2c5556abfe1d31008b6e10b0841c0b98ab8ae0aabc34564af1cbd7fbce642c
7
- data.tar.gz: d4d6f1434f014a87a0906732960217ba1ebbbe9d8ab3660e70544da151a856696f08fa2b07f938ced057440a6ab5d1677eec21ae4820a3ecac735bb2a5950d2f
6
+ metadata.gz: 9bb7bf12377be72a41e51a5de234475e2cbab1d3a8333b47865da959053d482b5bcb762d4c13fa00ed142a95bf64c531e2b04a22a608969440a7feb45dd881dc
7
+ data.tar.gz: 90e5eafb164fe1fdc2b11167b28a9f9a6fda24dc1be2ed98e987505b05bb4aabe7f8bb7b66b9141e658bdba0e70cc36a808be3a557a42ff19c944ef115d020ff
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
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
16
+
17
+ ### Added
18
+
19
+ - `.top` now accepts a required `limit` keyword argument; pass `nil` to retrieve all results
20
+
21
+ ### Fixed
22
+
23
+ - `require "weathercock"` now loads `Weathercock::Scorable` automatically
24
+
25
+ ## v0.1.0 - 2026-04-15
26
+
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,101 +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, decay_factor: nil, **window)
20
- return Weathercock.config.redis.call("ZRANGE", "#{weathercock_base_key(event)}:total", 0, -1, "REV") if window.empty?
21
-
22
- dest = weathercock_union(event, window, decay_factor: decay_factor)
23
- Weathercock.config.redis.call("ZRANGE", dest, 0, -1, "REV")
24
- end
25
-
26
- def hit_counts(event, ids:, **window)
27
- redis = Weathercock.config.redis
28
- dest = window.empty? ? "#{weathercock_base_key(event)}:total" : weathercock_union(event, window)
29
- ids.to_h { |id| [id.to_s, (redis.call("ZSCORE", dest, id.to_s) || "0").to_i] }
30
- end
31
-
32
- def weathercock_base_key(event)
33
- "#{Weathercock.config.namespace}:#{name.gsub("::", "_").downcase}:#{event}"
10
+ def weathercock_scorer
11
+ @weathercock_scorer ||= Scorer.new(klass: self)
34
12
  end
35
13
 
36
- private
37
-
38
- def weathercock_union(event, window, decay_factor: nil)
39
- redis = Weathercock.config.redis
40
- base = weathercock_base_key(event)
41
- type, count = window.first
42
- keys = weathercock_window_keys(base, type, count)
43
-
44
- dest = "#{base}:top:#{type}:#{count}"
45
- weights = decay_factor ? count.times.map { |i| (decay_factor**i).round(10) } : nil
46
- zunionstore_args = ["ZUNIONSTORE", dest, keys.size, *keys]
47
- zunionstore_args += ["WEIGHTS", *weights] if weights
48
- redis.call(*zunionstore_args)
49
- redis.call("EXPIRE", dest, 900)
50
- dest
14
+ def top(event, limit:, decay_factor: nil, **window)
15
+ weathercock_scorer.top(event, limit: limit, decay_factor: decay_factor, **window)
51
16
  end
52
17
 
53
- def weathercock_window_keys(base, type, count)
54
- now = Time.now
55
- case type
56
- when :hours
57
- count.times.map { |i| weathercock_bucket_key(base, type, now - (i * 3600)) }
58
- when :days
59
- count.times.map { |i| weathercock_bucket_key(base, type, now - (i * 86400)) }
60
- when :months
61
- d = Date.new(now.year, now.month)
62
- count.times.map { |i| weathercock_bucket_key(base, type, d << i) }
63
- end
64
- end
65
-
66
- def weathercock_bucket_key(base, type, time)
67
- case type
68
- when :hours then "#{base}:#{time.strftime("%Y-%m-%d-%H")}"
69
- when :days then "#{base}:#{time.strftime("%Y-%m-%d")}"
70
- when :months then "#{base}:#{time.strftime("%Y-%m")}"
71
- end
18
+ def hit_counts(event, ids:, **window)
19
+ weathercock_scorer.hit_counts(event, ids: ids, **window)
72
20
  end
73
21
  end
74
22
 
75
23
  def hit(event, increment: 1)
76
- now = Time.now
77
- redis = Weathercock.config.redis
78
- base = self.class.weathercock_base_key(event)
79
-
80
- redis.pipelined do |p|
81
- p.call("ZINCRBY", "#{base}:total", increment, id.to_s)
82
- WEATHERCOCK_BUCKET_TTLS.each do |type, ttl|
83
- key = self.class.send(:weathercock_bucket_key, base, type, now)
84
- p.call("ZINCRBY", key, increment, id.to_s)
85
- p.call("EXPIRE", key, ttl)
86
- end
87
- end
24
+ self.class.weathercock_scorer.hit(id, event, increment: increment)
88
25
  end
89
26
 
90
27
  def hit_count(event, **window)
91
- if window.empty?
92
- score = Weathercock.config.redis.call("ZSCORE", "#{self.class.weathercock_base_key(event)}:total", id.to_s)
93
- return score ? score.to_i : 0
94
- 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
95
34
 
96
- dest = self.class.send(:weathercock_union, event, window)
97
- score = Weathercock.config.redis.call("ZSCORE", dest, id.to_s)
98
- score ? score.to_i : 0
35
+ def remove_hits(event)
36
+ self.class.weathercock_scorer.remove_hits(id, event)
99
37
  end
100
38
  end
101
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 = "0.1.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/weathercock.rb CHANGED
@@ -1,6 +1,9 @@
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"
6
+ require_relative "weathercock/scorable"
4
7
 
5
8
  module Weathercock
6
9
  class Error < StandardError; end
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: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takafumi ONAKA
@@ -45,12 +45,15 @@ executables: []
45
45
  extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
+ - CHANGELOG.md
48
49
  - LICENSE.txt
49
50
  - README.md
50
51
  - Rakefile
51
52
  - compose.yaml
52
53
  - lib/weathercock.rb
54
+ - lib/weathercock/key_builder.rb
53
55
  - lib/weathercock/scorable.rb
56
+ - lib/weathercock/scorer.rb
54
57
  - lib/weathercock/version.rb
55
58
  - sig/weathercock.rbs
56
59
  homepage: https://github.com/onk/weathercock