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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +28 -5
- data/lib/weathercock/key_builder.rb +45 -0
- data/lib/weathercock/scorable.rb +15 -77
- data/lib/weathercock/scorer.rb +96 -0
- data/lib/weathercock/version.rb +1 -1
- data/lib/weathercock.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc7554f2d86d4cd3499ea0aa89d77bc327e9667b16257b234f00391d82118f8b
|
|
4
|
+
data.tar.gz: 7687c05fe5724b8ec134bfbaf9fc4689b799415f368627a85de790b8ddcd7073
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/weathercock/scorable.rb
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
data/lib/weathercock/version.rb
CHANGED
data/lib/weathercock.rb
CHANGED
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:
|
|
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
|