Skip to content

Commit 467252f

Browse files
committed
Handle single-name bulk slice and write cursor without clobbering metadata
api.npmjs.org returns {downloads: N, package: name} for a single-name request rather than {name: {downloads: N}}, so a bulk slice of length 1 was silently dropped by the hash-of-hashes parser. Route single-name slices through the per-package path instead. The cursor write was update_column(:metadata, merged_hash), which overwrites the whole json value with whatever this process loaded plus the cursor key. metadata also holds rate_limit, which is tuned live via console, so a rate_limit change made while a batch was in flight would be lost when the cursor landed. Registry#merge_metadata_key does the merge in SQL via jsonb || so only the one key is written. Also add desc lines to the two rake tasks.
1 parent fe68cb6 commit 467252f

6 files changed

Lines changed: 57 additions & 5 deletions

File tree

app/models/ecosystem/npm.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ def fetch_download_counts(names)
151151
scoped, unscoped = names.partition { |n| n.start_with?('@') }
152152
counts = {}
153153
unscoped.each_slice(BULK_DOWNLOADS_LIMIT) do |batch|
154+
if batch.length == 1
155+
scoped << batch.first
156+
next
157+
end
154158
json = get_json("https://api.npmjs.org/downloads/point/last-month/#{batch.join(',')}")
155159
next unless json.is_a?(Hash)
156160
json.each { |name, data| counts[name] = data['downloads'] if data.is_a?(Hash) }

app/models/registry.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ def self.update_extra_counts
8787
all.each(&:update_extra_counts)
8888
end
8989

90+
def merge_metadata_key(key, value)
91+
Registry.where(id: id).update_all([
92+
"metadata = (COALESCE(metadata, '{}')::jsonb || jsonb_build_object(?, ?::text))::json",
93+
key.to_s, value.to_s
94+
])
95+
reload
96+
end
97+
9098
def update_download_counts(limit: 1000, top: false)
9199
return 0 unless ecosystem_instance.respond_to?(:fetch_download_counts)
92100
scope = packages.where("status IS NULL OR status <> ?", 'removed')
@@ -96,7 +104,7 @@ def update_download_counts(limit: 1000, top: false)
96104
cursor = (metadata || {})['download_counts_cursor'].to_s
97105
batch = scope.where('name > ?', cursor).order(:name).limit(limit).pluck(:id, :name)
98106
if batch.empty?
99-
update_column(:metadata, (metadata || {}).merge('download_counts_cursor' => '')) unless cursor.blank?
107+
merge_metadata_key('download_counts_cursor', '') unless cursor.blank?
100108
return 0
101109
end
102110
end
@@ -111,7 +119,7 @@ def update_download_counts(limit: 1000, top: false)
111119
Package.where(id: ids).update_all(downloads_updated_at: now)
112120
end
113121
end
114-
update_column(:metadata, (metadata || {}).merge('download_counts_cursor' => batch.last.last)) unless top
122+
merge_metadata_key('download_counts_cursor', batch.last.last) unless top
115123
batch.length
116124
end
117125

lib/tasks/packages.rake

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ namespace :packages do
132132
end
133133
end
134134

135+
desc 'walk npm packages by name cursor and refresh download counts'
135136
task update_download_counts: :environment do
136137
with_rake_lock('packages:update_download_counts') do
137138
Registry.where(ecosystem: 'npm').find_each do |r|
@@ -140,6 +141,7 @@ namespace :packages do
140141
end
141142
end
142143

144+
desc 'refresh download counts for the top-download npm packages'
143145
task update_top_download_counts: :environment do
144146
with_rake_lock('packages:update_top_download_counts') do
145147
Registry.where(ecosystem: 'npm').find_each do |r|

test/models/ecosystem/npm_test.rb

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,13 @@ class NpmTest < ActiveSupport::TestCase
160160
end
161161

162162
test 'fetch_download_counts fetches scoped names individually' do
163-
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/lodash")
164-
.to_return(status: 200, body: '{"lodash":{"downloads":5}}', headers: { 'Content-Type' => 'application/json' })
163+
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/lodash,express")
164+
.to_return(status: 200, body: '{"lodash":{"downloads":5},"express":{"downloads":6}}', headers: { 'Content-Type' => 'application/json' })
165165
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/@scope/pkg")
166166
.to_return(status: 200, body: '{"downloads":7,"package":"@scope/pkg"}', headers: { 'Content-Type' => 'application/json' })
167-
counts = @ecosystem.fetch_download_counts(['@scope/pkg', 'lodash'])
167+
counts = @ecosystem.fetch_download_counts(['@scope/pkg', 'lodash', 'express'])
168168
assert_equal 5, counts['lodash']
169+
assert_equal 6, counts['express']
169170
assert_equal 7, counts['@scope/pkg']
170171
end
171172

@@ -179,6 +180,21 @@ class NpmTest < ActiveSupport::TestCase
179180
assert_equal 1, counts['pkg129']
180181
end
181182

183+
test 'fetch_download_counts routes a lone unscoped name through the single-package path' do
184+
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/lodash")
185+
.to_return(status: 200, body: '{"downloads":9,"package":"lodash"}', headers: { 'Content-Type' => 'application/json' })
186+
assert_equal({ 'lodash' => 9 }, @ecosystem.fetch_download_counts(['lodash']))
187+
end
188+
189+
test 'fetch_download_counts routes a single-name trailing slice through the single-package path' do
190+
names = (1..129).map { |i| "pkg#{i}" }
191+
stub_request(:get, %r{https://api\.npmjs\.org/downloads/point/last-month/pkg1,.*,pkg128$})
192+
.to_return(status: 200, body: '{}', headers: { 'Content-Type' => 'application/json' })
193+
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/pkg129")
194+
.to_return(status: 200, body: '{"downloads":3,"package":"pkg129"}', headers: { 'Content-Type' => 'application/json' })
195+
assert_equal 3, @ecosystem.fetch_download_counts(names)['pkg129']
196+
end
197+
182198
test 'fetch_download_counts ignores non-hash bulk response' do
183199
stub_request(:get, "https://api.npmjs.org/downloads/point/last-month/a,b")
184200
.to_return(status: 500, body: 'null', headers: { 'Content-Type' => 'application/json' })

test/models/registry_test.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,22 @@ class RegistryTest < ActiveSupport::TestCase
427427
assert_equal 3, stat_2022.packages_count
428428
end
429429

430+
test 'merge_metadata_key preserves keys written by another connection after load' do
431+
@registry.update!(metadata: { 'rate_limit' => 1 })
432+
stale = Registry.find(@registry.id)
433+
Registry.where(id: @registry.id).update_all("metadata = (metadata::jsonb || '{\"rate_limit\":5}'::jsonb)::json")
434+
stale.merge_metadata_key('download_counts_cursor', 'zzz')
435+
fresh = Registry.find(@registry.id)
436+
assert_equal 5, fresh.metadata['rate_limit']
437+
assert_equal 'zzz', fresh.metadata['download_counts_cursor']
438+
end
439+
440+
test 'merge_metadata_key handles empty metadata' do
441+
@registry.update_column(:metadata, nil)
442+
@registry.merge_metadata_key('download_counts_cursor', 'abc')
443+
assert_equal 'abc', @registry.metadata['download_counts_cursor']
444+
end
445+
430446
test 'throttled_ids returns registries with a rate_limit' do
431447
Registry.reset_throttle_cache
432448
throttled = Registry.create!(name: 'npmjs.org', url: 'https://registry.npmjs.org', ecosystem: 'npm', metadata: { 'rate_limit' => 1 })

test/sidekiq/update_download_counts_worker_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ class UpdateDownloadCountsWorkerTest < ActiveSupport::TestCase
5353
assert_nil @registry.reload.metadata['download_counts_cursor']
5454
end
5555

56+
test "cursor write goes through merge_metadata_key" do
57+
@registry.expects(:merge_metadata_key).with('download_counts_cursor', 'bbb')
58+
@registry.ecosystem_instance.expects(:fetch_download_counts).with(['aaa', 'bbb']).returns({})
59+
@registry.update_download_counts(limit: 10)
60+
end
61+
5662
test "update_download_counts is a no-op when ecosystem has no fetch_download_counts" do
5763
r = Registry.create!(name: 'crates.io', url: 'https://crates.io', ecosystem: 'cargo')
5864
r.packages.create!(name: 'x', ecosystem: 'cargo')

0 commit comments

Comments
 (0)