Skip to content

Commit 96309ba

Browse files
jtgeibelTurbo87
authored andcommitted
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 62b4ac7 commit 96309ba

File tree

3 files changed

+116
-48
lines changed

3 files changed

+116
-48
lines changed

src/app.rs

+8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::downloads_counter::DownloadsCounter;
88
use crate::email::Emails;
99
use crate::github::GitHubClient;
1010
use crate::metrics::{InstanceMetrics, ServiceMetrics};
11+
use dashmap::DashMap;
1112
use diesel::r2d2;
1213
use oauth2::basic::BasicClient;
1314
use reqwest::blocking::Client;
@@ -31,6 +32,12 @@ pub struct App {
3132
/// The server configuration
3233
pub config: config::Server,
3334

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

@@ -166,6 +173,7 @@ impl App {
166173
github,
167174
github_oauth,
168175
config,
176+
version_id_cacher: DashMap::new(),
169177
downloads_counter: DownloadsCounter::new(),
170178
emails: Emails::from_environment(),
171179
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

+35
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,38 @@ fn download_noncanonical_crate_name() {
111111
anon.get::<()>("/api/v1/crates/foo-download/1.0.0/download")
112112
.assert_redirect_ends_with("/crates/foo_download/foo_download-1.0.0.crate");
113113
}
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)