weathercock 1.0.0 → 1.2.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: e6f2f450b510bee3dbea48bf0b4e7e37ef24040d82141caaabe45d6dce3c3cb6
4
+ data.tar.gz: ab78b22090a84493d97b3f5deca74963f150d3ea21af0b47408babb64fdb7985
5
5
  SHA512:
6
- metadata.gz: cb6fb7895358325549f117b807b75889a0a49ca839deadd4347b4d137c156cd7dac3acf4303b3845a5b11f02bcdc402eb95b8d18feb5fe80b7973676c5cb1939
7
- data.tar.gz: 4d347a60ccb2ff6d823bb1586d9ef683702d72a7973ea9a3825f1a3c5f7eeb72f0e110cca5f14f6bf098a5f9076c6d60b6b91ad1b088d6ab85b115294efeda82
6
+ metadata.gz: e10e7fee6c2e629c994c097920af3226a3f6fdde65a18aed9a85b5556ae6d0a4b4abf78029b833d50d88581745f2517911177ebaee78bbe7de6aef2c78eacb49
7
+ data.tar.gz: 4a2089ecaceb03a54cb1462504f9b946145b81837db87c4a497650164180c56533246f851ab0c1e1c8f84a337f06faf767c782960e8a9ac6273149f0e69f5766
data/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## [1.0.0] - 2026-04-15
3
+ ## v1.2.0 - 2026-09-16
4
+
5
+ ### Changed
6
+
7
+ - `union` now reuses an existing destination key instead of always recomputing it, so its 900 second TTL works as a cache
8
+ - `union_dest` now includes `decay_factor` so decayed and non-decayed results no longer share a cache entry
9
+
10
+ ## v1.1.0 - 2026-04-17
11
+
12
+ ### Added
13
+
14
+ - `#rank` returns the 1-indexed ranking position of an instance; returns `nil` if unranked
15
+ - `#remove_hits` removes all recorded hits for an instance across all keys
16
+
17
+ ### Changed
18
+
19
+ - Bucket count unified to 90 across all time granularities; data is retained longer
20
+ - `hit_counts` now uses a single ZMSCORE call instead of N ZSCORE calls
21
+
22
+ ## v1.0.0 - 2026-04-15
4
23
 
5
24
  ### Added
6
25
 
@@ -10,6 +29,6 @@
10
29
 
11
30
  - `require "weathercock"` now loads `Weathercock::Scorable` automatically
12
31
 
13
- ## [0.1.0] - 2026-04-15
32
+ ## v0.1.0 - 2026-04-15
14
33
 
15
34
  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.
data/Steepfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ D = Steep::Diagnostic
4
+
5
+ target :lib do
6
+ signature "sig"
7
+
8
+ check "lib"
9
+
10
+ library "date"
11
+
12
+ configure_code_diagnostics(D::Ruby.default)
13
+ end
@@ -0,0 +1,46 @@
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, decay_factor: nil)
42
+ dest = "#{base}:top:#{type}:#{count}"
43
+ decay_factor ? "#{dest}:decay:#{decay_factor}" : dest
44
+ end
45
+ end
46
+ 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,104 @@
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 do |id, score|
78
+ [id.to_s, Integer(score || "0")] #: [String, Integer]
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def union(event, window, decay_factor: nil)
85
+ base = @key_builder.base(event)
86
+ type, count = window.first
87
+ dest = if decay_factor
88
+ @key_builder.union_dest(base, type, count, decay_factor: decay_factor)
89
+ else
90
+ @key_builder.union_dest(base, type, count)
91
+ end
92
+
93
+ unless @redis.call("EXISTS", dest) == 1
94
+ keys = @key_builder.window_keys(base, type, count)
95
+ weights = decay_factor ? count.times.map { |i| (decay_factor**i).round(10) } : nil
96
+ zunionstore_args = ["ZUNIONSTORE", dest, keys.size, *keys]
97
+ zunionstore_args += ["WEIGHTS", *weights] if weights
98
+ @redis.call(*zunionstore_args)
99
+ end
100
+ @redis.call("EXPIRE", dest, 900)
101
+ dest
102
+ end
103
+ end
104
+ 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.2.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
@@ -0,0 +1,13 @@
1
+ class Weathercock::KeyBuilder
2
+ def initialize: (namespace: String, klass: Module) -> void
3
+
4
+ def base: (Symbol) -> String
5
+
6
+ def total: (String) -> String
7
+
8
+ def bucket: (String, :hours | :days | :months, Date | Time) -> String
9
+
10
+ def window_keys: (String, :hours | :days | :months, Integer) -> Array[String]
11
+
12
+ def union_dest: (String, :hours | :days | :months, Integer, ?decay_factor: Float) -> String
13
+ end
@@ -0,0 +1,25 @@
1
+ module Weathercock::Scorable
2
+ def self.included: (Module) -> void
3
+ # steep infers self.class in instance methods as singleton(Scorable),
4
+ # so declare weathercock_scorer here to satisfy the type checker.
5
+ def self.weathercock_scorer: () -> Weathercock::Scorer
6
+
7
+ # Expected to be provided by the including class (e.g. ActiveRecord).
8
+ def id: () -> Weathercock::id
9
+
10
+ def hit: (Symbol, ?increment: Integer) -> void
11
+
12
+ def hit_count: (Symbol, ?hours: Integer, ?days: Integer, ?months: Integer) -> Integer
13
+
14
+ def rank: (Symbol, ?hours: Integer, ?days: Integer, ?months: Integer) -> Integer?
15
+
16
+ def remove_hits: (Symbol) -> void
17
+ end
18
+
19
+ module Weathercock::Scorable::ClassMethods : Module
20
+ def weathercock_scorer: () -> Weathercock::Scorer
21
+
22
+ def top: (Symbol, limit: Integer?, ?decay_factor: Float, ?hours: Integer, ?days: Integer, ?months: Integer) -> Array[String]
23
+
24
+ def hit_counts: (Symbol, ids: Array[Weathercock::id], ?hours: Integer, ?days: Integer, ?months: Integer) -> Hash[String, Integer]
25
+ end
@@ -0,0 +1,20 @@
1
+ class Weathercock::Scorer
2
+ BUCKET_COUNT: Integer
3
+ BUCKET_TTLS: Hash[:hours | :days | :months, Integer]
4
+
5
+ def initialize: (klass: Module) -> void
6
+
7
+ def hit: (Weathercock::id, Symbol, ?increment: Integer) -> void
8
+
9
+ def hit_count: (Weathercock::id, Symbol, ?hours: Integer, ?days: Integer, ?months: Integer) -> Integer
10
+
11
+ def union: (Symbol, Hash[untyped, untyped], ?decay_factor: Float) -> String
12
+
13
+ def top: (Symbol, limit: Integer?, ?decay_factor: Float, ?hours: Integer, ?days: Integer, ?months: Integer) -> Array[String]
14
+
15
+ def hit_counts: (Symbol, ids: Array[Weathercock::id], ?hours: Integer, ?days: Integer, ?months: Integer) -> Hash[String, Integer]
16
+
17
+ def rank: (Weathercock::id, Symbol, ?hours: Integer, ?days: Integer, ?months: Integer) -> Integer?
18
+
19
+ def remove_hits: (Weathercock::id, Symbol) -> void
20
+ end
data/sig/weathercock.rbs CHANGED
@@ -1,4 +1,18 @@
1
1
  module Weathercock
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ type id = Integer | String
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ class Config
10
+ attr_accessor namespace: String
11
+ attr_accessor redis: untyped
12
+
13
+ def initialize: () -> void
14
+ end
15
+
16
+ def self.configure: () { (Config) -> void } -> void
17
+ def self.config: () -> Config
4
18
  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: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Takafumi ONAKA
@@ -49,11 +49,17 @@ files:
49
49
  - LICENSE.txt
50
50
  - README.md
51
51
  - Rakefile
52
+ - Steepfile
52
53
  - compose.yaml
53
54
  - lib/weathercock.rb
55
+ - lib/weathercock/key_builder.rb
54
56
  - lib/weathercock/scorable.rb
57
+ - lib/weathercock/scorer.rb
55
58
  - lib/weathercock/version.rb
56
59
  - sig/weathercock.rbs
60
+ - sig/weathercock/key_builder.rbs
61
+ - sig/weathercock/scorable.rbs
62
+ - sig/weathercock/scorer.rbs
57
63
  homepage: https://github.com/onk/weathercock
58
64
  licenses:
59
65
  - MIT
@@ -75,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
75
81
  - !ruby/object:Gem::Version
76
82
  version: '0'
77
83
  requirements: []
78
- rubygems_version: 4.0.6
84
+ rubygems_version: 4.0.16
79
85
  specification_version: 4
80
86
  summary: Hit counter and popularity tracking using Valkey/Redis Sorted Sets
81
87
  test_files: []