Skip to content

Commit 284565d

Browse files
committed
Cache the version_id for download requests
This adds a caching layer in the app backend to reduce the number of database queries. While this query is extremely fast, a massive number of concurrent download requests can still result in a request backlog. In this implementation cached values are never dropped. This means that memory usage will grow over time, however the memory usage is expected to be well within our instance size and the application is automatically rebooted at least once every 24 hours. Additionally, on the rare occasion that a crate version is deleted, the cache will continue to serve redirects and attempt to record download counts for that version. If a version is deleted and continues to see download requests, it is expected that the entire shard of downloads containing that version_id will fail to persist. Even if we expired cached entries after a certain time period, there would still be a window where a deleted version could cause download counts for other versions to not be persisted. It is possible we could make the download counter logic robust against this scenario, but given that deleting versions is rare we can mitigate this by restarting the app after deleting crates or versions.
1 parent 8b177ea commit 284565d

File tree

3 files changed

+159
-84
lines changed

3 files changed

+159
-84
lines changed

src/app.rs

+9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use crate::email::Emails;
99
use crate::github::GitHubClient;
1010
use crate::metrics::{InstanceMetrics, ServiceMetrics};
1111
use crate::rate_limiter::RateLimiter;
12+
13+
use dashmap::DashMap;
1214
use diesel::r2d2;
1315
use oauth2::basic::BasicClient;
1416
use reqwest::blocking::Client;
@@ -32,6 +34,12 @@ pub struct App {
3234
/// The server configuration
3335
pub config: config::Server,
3436

37+
/// Cache the `version_id` of a `canonical_crate_name:semver` pair
38+
///
39+
/// This is used by the download endpoint to reduce the number of database queries. The
40+
/// `version_id` is only cached under the canonical spelling of the crate name.
41+
pub(crate) version_id_cacher: DashMap<String, i32>,
42+
3543
/// Count downloads and periodically persist them in the database
3644
pub downloads_counter: DownloadsCounter,
3745

@@ -176,6 +184,7 @@ impl App {
176184
github_oauth,
177185
rate_limiter: RateLimiter::new(config.rate_limiter.clone()),
178186
config,
187+
version_id_cacher: DashMap::new(),
179188
downloads_counter: DownloadsCounter::new(),
180189
emails: Emails::from_environment(),
181190
service_metrics: ServiceMetrics::new().expect("could not initialize service metrics"),

src/controllers/version/downloads.rs

+73-48
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use crate::db::PoolError;
88
use crate::models::{Crate, VersionDownload};
99
use crate::schema::*;
1010
use crate::views::EncodableVersionDownload;
11+
1112
use chrono::{Duration, NaiveDate, Utc};
13+
use dashmap::mapref::entry::Entry;
1214

1315
/// Handles the `GET /crates/:crate_id/:version/download` route.
1416
/// This returns a URL to the location where the crate is stored.
@@ -19,59 +21,82 @@ pub fn download(req: &mut dyn RequestExt) -> EndpointResult {
1921
let version = req.params()["version"].as_str();
2022

2123
let mut log_metadata = None;
22-
match req.db_conn() {
23-
Ok(conn) => {
24-
use self::versions::dsl::*;
25-
26-
// Returns the crate name as stored in the database, or an error if we could
27-
// not load the version ID from the database.
28-
let (version_id, canonical_crate_name) = app
29-
.instance_metrics
30-
.downloads_select_query_execution_time
31-
.observe_closure_duration(|| {
32-
versions
33-
.inner_join(crates::table)
34-
.select((id, crates::name))
35-
.filter(Crate::with_name(&crate_name))
36-
.filter(num.eq(version))
37-
.first::<(i32, String)>(&*conn)
38-
})?;
39-
40-
if canonical_crate_name != crate_name {
41-
app.instance_metrics
42-
.downloads_non_canonical_crate_name_total
43-
.inc();
44-
log_metadata = Some(("bot", "dl"));
45-
}
46-
crate_name = canonical_crate_name;
24+
25+
match app
26+
.version_id_cacher
27+
.entry(format!("{}:{}", crate_name, version))
28+
{
29+
// The version_id is cached. This also means that the provided crate_name is canonical
30+
// and that no fixup is necessary before redirecting.
31+
Entry::Occupied(entry) => {
32+
let version_id = *entry.get();
4733

4834
// The increment does not happen instantly, but it's deferred to be executed in a batch
4935
// along with other downloads. See crate::downloads_counter for the implementation.
5036
app.downloads_counter.increment(version_id);
5137
}
52-
Err(PoolError::UnhealthyPool) => {
53-
// The download endpoint is the most critical route in the whole crates.io application,
54-
// as it's relied upon by users and automations to download crates. Keeping it working
55-
// is the most important thing for us.
56-
//
57-
// The endpoint relies on the database to fetch the canonical crate name (with the
58-
// right capitalization and hyphenation), but that's only needed to serve clients who
59-
// don't call the endpoint with the crate's canonical name.
60-
//
61-
// Thankfully Cargo always uses the right name when calling the endpoint, and we can
62-
// keep it working during a full database outage by unconditionally redirecting without
63-
// checking whether the crate exists or the rigth name is used. Non-Cargo clients might
64-
// get a 404 response instead of a 500, but that's worth it.
65-
//
66-
// Without a working database we also can't count downloads, but that's also less
67-
// critical than keeping Cargo downloads operational.
68-
69-
app.instance_metrics
70-
.downloads_unconditional_redirects_total
71-
.inc();
72-
log_metadata = Some(("unconditional_redirect", "true"));
73-
}
74-
Err(err) => return Err(err.into()),
38+
39+
// The version_id is not cached, so either query the database or fallback to an
40+
// unconditional redirect if the database pool is unhealthy.
41+
Entry::Vacant(entry) => match req.db_conn() {
42+
Ok(conn) => {
43+
use self::versions::dsl::*;
44+
45+
// Returns the crate name as stored in the database, or an error if we could
46+
// not load the version ID from the database.
47+
let (version_id, canonical_crate_name) = app
48+
.instance_metrics
49+
.downloads_select_query_execution_time
50+
.observe_closure_duration(|| {
51+
versions
52+
.inner_join(crates::table)
53+
.select((id, crates::name))
54+
.filter(Crate::with_name(&crate_name))
55+
.filter(num.eq(version))
56+
.first::<(i32, String)>(&*conn)
57+
})?;
58+
59+
if canonical_crate_name != crate_name {
60+
app.instance_metrics
61+
.downloads_non_canonical_crate_name_total
62+
.inc();
63+
log_metadata = Some(("bot", "dl"));
64+
crate_name = canonical_crate_name;
65+
} else {
66+
// The version_id is only cached if the provided crate name was canonical.
67+
// Non-canonical requests fallback to the "slow" path with a DB query, but
68+
// we typically only get a few hundred non-canonical requests in a day anyway.
69+
entry.insert(version_id);
70+
}
71+
72+
// The increment does not happen instantly, but it's deferred to be executed in a batch
73+
// along with other downloads. See crate::downloads_counter for the implementation.
74+
app.downloads_counter.increment(version_id);
75+
}
76+
Err(PoolError::UnhealthyPool) => {
77+
// The download endpoint is the most critical route in the whole crates.io application,
78+
// as it's relied upon by users and automations to download crates. Keeping it working
79+
// is the most important thing for us.
80+
//
81+
// The endpoint relies on the database to fetch the canonical crate name (with the
82+
// right capitalization and hyphenation), but that's only needed to serve clients who
83+
// don't call the endpoint with the crate's canonical name.
84+
//
85+
// Thankfully Cargo always uses the right name when calling the endpoint, and we can
86+
// keep it working during a full database outage by unconditionally redirecting without
87+
// checking whether the crate exists or the rigth name is used. Non-Cargo clients might
88+
// get a 404 response instead of a 500, but that's worth it.
89+
//
90+
// Without a working database we also can't count downloads, but that's also less
91+
// critical than keeping Cargo downloads operational.
92+
93+
app.instance_metrics
94+
.downloads_unconditional_redirects_total
95+
.inc();
96+
log_metadata = Some(("unconditional_redirect", "true"));
97+
}
98+
Err(err) => return Err(err.into()),
99+
},
75100
}
76101

77102
let redirect_url = req

src/tests/krate/downloads.rs

+77-36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::builders::{CrateBuilder, VersionBuilder};
2-
use crate::util::{RequestHelper, TestApp};
2+
use crate::util::{MockAnonymousUser, RequestHelper, TestApp};
33
use cargo_registry::views::EncodableVersionDownload;
44
use chrono::{Duration, Utc};
55
use http::StatusCode;
@@ -9,6 +9,35 @@ struct Downloads {
99
version_downloads: Vec<EncodableVersionDownload>,
1010
}
1111

12+
fn persist_downloads_count(app: &TestApp) {
13+
app.as_inner()
14+
.downloads_counter
15+
.persist_all_shards(app.as_inner())
16+
.expect("failed to persist downloads count")
17+
.log();
18+
}
19+
20+
#[track_caller]
21+
fn assert_dl_count(
22+
anon: &MockAnonymousUser,
23+
name_and_version: &str,
24+
query: Option<&str>,
25+
count: i32,
26+
) {
27+
let url = format!("/api/v1/crates/{}/downloads", name_and_version);
28+
let downloads: Downloads = if let Some(query) = query {
29+
anon.get_with_query(&url, query).good()
30+
} else {
31+
anon.get(&url).good()
32+
};
33+
let total_downloads = downloads
34+
.version_downloads
35+
.iter()
36+
.map(|vd| vd.downloads)
37+
.sum::<i32>();
38+
assert_eq!(total_downloads, count);
39+
}
40+
1241
#[test]
1342
fn download() {
1443
let (app, anon, user) = TestApp::init().with_user();
@@ -20,60 +49,37 @@ fn download() {
2049
.expect_build(conn);
2150
});
2251

23-
let assert_dl_count = |name_and_version: &str, query: Option<&str>, count: i32| {
24-
let url = format!("/api/v1/crates/{}/downloads", name_and_version);
25-
let downloads: Downloads = if let Some(query) = query {
26-
anon.get_with_query(&url, query).good()
27-
} else {
28-
anon.get(&url).good()
29-
};
30-
let total_downloads = downloads
31-
.version_downloads
32-
.iter()
33-
.map(|vd| vd.downloads)
34-
.sum::<i32>();
35-
assert_eq!(total_downloads, count);
36-
};
37-
3852
let download = |name_and_version: &str| {
3953
let url = format!("/api/v1/crates/{}/download", name_and_version);
4054
let response = anon.get::<()>(&url);
4155
assert_eq!(response.status(), StatusCode::FOUND);
4256
// TODO: test the with_json code path
4357
};
4458

45-
let persist_downloads_count = || {
46-
app.as_inner()
47-
.downloads_counter
48-
.persist_all_shards(app.as_inner())
49-
.expect("failed to persist downloads count")
50-
.log();
51-
};
52-
5359
download("foo_download/1.0.0");
5460
// No downloads are counted until the counters are persisted
55-
assert_dl_count("foo_download/1.0.0", None, 0);
56-
assert_dl_count("foo_download", None, 0);
57-
persist_downloads_count();
61+
assert_dl_count(&anon, "foo_download/1.0.0", None, 0);
62+
assert_dl_count(&anon, "foo_download", None, 0);
63+
persist_downloads_count(&app);
5864
// Now that the counters are persisted the download counts show up.
59-
assert_dl_count("foo_download/1.0.0", None, 1);
60-
assert_dl_count("foo_download", None, 1);
65+
assert_dl_count(&anon, "foo_download/1.0.0", None, 1);
66+
assert_dl_count(&anon, "foo_download", None, 1);
6167

6268
download("FOO_DOWNLOAD/1.0.0");
63-
persist_downloads_count();
64-
assert_dl_count("FOO_DOWNLOAD/1.0.0", None, 2);
65-
assert_dl_count("FOO_DOWNLOAD", None, 2);
69+
persist_downloads_count(&app);
70+
assert_dl_count(&anon, "FOO_DOWNLOAD/1.0.0", None, 2);
71+
assert_dl_count(&anon, "FOO_DOWNLOAD", None, 2);
6672

6773
let yesterday = (Utc::today() + Duration::days(-1)).format("%F");
6874
let query = format!("before_date={}", yesterday);
69-
assert_dl_count("FOO_DOWNLOAD/1.0.0", Some(&query), 0);
75+
assert_dl_count(&anon, "FOO_DOWNLOAD/1.0.0", Some(&query), 0);
7076
// crate/downloads always returns the last 90 days and ignores date params
71-
assert_dl_count("FOO_DOWNLOAD", Some(&query), 2);
77+
assert_dl_count(&anon, "FOO_DOWNLOAD", Some(&query), 2);
7278

7379
let tomorrow = (Utc::today() + Duration::days(1)).format("%F");
7480
let query = format!("before_date={}", tomorrow);
75-
assert_dl_count("FOO_DOWNLOAD/1.0.0", Some(&query), 2);
76-
assert_dl_count("FOO_DOWNLOAD", Some(&query), 2);
81+
assert_dl_count(&anon, "FOO_DOWNLOAD/1.0.0", Some(&query), 2);
82+
assert_dl_count(&anon, "FOO_DOWNLOAD", Some(&query), 2);
7783
}
7884

7985
#[test]
@@ -105,3 +111,38 @@ fn download_noncanonical_crate_name() {
105111
anon.get::<()>("/api/v1/crates/foo-download/1.0.0/download")
106112
.assert_redirect_ends_with("/crates/foo_download/foo_download-1.0.0.crate");
107113
}
114+
115+
#[test]
116+
fn download_caches_version_id() {
117+
use cargo_registry::schema::crates::dsl::*;
118+
use diesel::prelude::*;
119+
120+
let (app, anon, user) = TestApp::init().with_user();
121+
let user = user.as_model();
122+
123+
app.db(|conn| {
124+
CrateBuilder::new("foo_download", user.id)
125+
.version(VersionBuilder::new("1.0.0"))
126+
.expect_build(conn);
127+
});
128+
129+
anon.get::<()>("/api/v1/crates/foo_download/1.0.0/download")
130+
.assert_redirect_ends_with("/crates/foo_download/foo_download-1.0.0.crate");
131+
132+
// Rename the crate, so that `foo_download` will not be found if its version_id was not cached
133+
app.db(|conn| {
134+
diesel::update(crates.filter(name.eq("foo_download")))
135+
.set(name.eq("other"))
136+
.execute(conn)
137+
.unwrap();
138+
});
139+
140+
// This would result in a 404 if the endpoint tried to read from the database
141+
anon.get::<()>("/api/v1/crates/foo_download/1.0.0/download")
142+
.assert_redirect_ends_with("/crates/foo_download/foo_download-1.0.0.crate");
143+
144+
// Downloads are persisted by version_id, so the rename doesn't matter
145+
persist_downloads_count(&app);
146+
// Check download count against the new name, rather than rename it back to the original value
147+
assert_dl_count(&anon, "other/1.0.0", None, 2);
148+
}

0 commit comments

Comments
 (0)