Skip to content

Commit 2f976d8

Browse files
committed
feat: implement stale-while-revalidate for HTTP cache
1 parent cb21578 commit 2f976d8

File tree

2 files changed

+100
-36
lines changed

2 files changed

+100
-36
lines changed

src/net/http.rs

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -93,19 +93,28 @@ where
9393
Ok(sender)
9494
}
9595

96-
/// Converts the URL to expiration timestamp.
97-
fn http_url_cache_expires(url: &str, mimetype: Option<&str>) -> i64 {
96+
/// Converts the URL to expiration and stale timestamps.
97+
fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
9898
let now = time();
99-
if url.ends_with(".xdc") {
100-
// WebXDCs expire in 5 weeks.
101-
now + 3600 * 24 * 35
99+
100+
let expires = now + 3600 * 24 * 35;
101+
let stale = if url.ends_with(".xdc") {
102+
// WebXDCs are never stale, they just expire.
103+
expires
102104
} else if mimetype.is_some_and(|s| s.starts_with("image/")) {
103105
// Cache images for 1 day.
106+
//
107+
// As of 2024-12-12 WebXDC icons at <https://webxdc.org/apps/>
108+
// use the same path for all app versions,
109+
// so may change, but it is not critical if outdated icon is displayed.
104110
now + 3600 * 24
105111
} else {
106-
// Cache everything else for 1 hour.
112+
// Revalidate everything else after 1 hour.
113+
//
114+
// This includes HTML, CSS and JS.
107115
now + 3600
108-
}
116+
};
117+
(expires, stale)
109118
}
110119

111120
/// Places the binary into HTTP cache.
@@ -117,14 +126,16 @@ async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Re
117126
)
118127
.await?;
119128

129+
let (expires, stale) = http_url_cache_timestamps(url, response.mimetype.as_deref());
120130
context
121131
.sql
122132
.insert(
123-
"INSERT OR REPLACE INTO http_cache (url, expires, blobname, mimetype, encoding)
124-
VALUES (?, ?, ?, ?, ?)",
133+
"INSERT OR REPLACE INTO http_cache (url, expires, stale, blobname, mimetype, encoding)
134+
VALUES (?, ?, ?, ?, ?, ?)",
125135
(
126136
url,
127-
http_url_cache_expires(url, response.mimetype.as_deref()),
137+
expires,
138+
stale,
128139
blob.as_name(),
129140
response.mimetype.as_deref().unwrap_or_default(),
130141
response.encoding.as_deref().unwrap_or_default(),
@@ -136,18 +147,22 @@ async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Re
136147
}
137148

138149
/// Retrieves the binary from HTTP cache.
139-
async fn http_cache_get(context: &Context, url: &str) -> Result<Option<Response>> {
140-
let Some((blob_name, mimetype, encoding)) = context
150+
///
151+
/// Also returns if the response is stale and should be revalidated in the background.
152+
async fn http_cache_get(context: &Context, url: &str) -> Result<Option<(Response, bool)>> {
153+
let now = time();
154+
let Some((blob_name, mimetype, encoding, is_stale)) = context
141155
.sql
142156
.query_row_optional(
143-
"SELECT blobname, mimetype, encoding
157+
"SELECT blobname, mimetype, encoding, stale
144158
FROM http_cache WHERE url=? AND expires > ?",
145-
(url, time()),
159+
(url, now),
146160
|row| {
147161
let blob_name: String = row.get(0)?;
148162
let mimetype: Option<String> = Some(row.get(1)?).filter(|s: &String| !s.is_empty());
149163
let encoding: Option<String> = Some(row.get(2)?).filter(|s: &String| !s.is_empty());
150-
Ok((blob_name, mimetype, encoding))
164+
let stale_timestamp: i64 = row.get(3)?;
165+
Ok((blob_name, mimetype, encoding, now > stale_timestamp))
151166
},
152167
)
153168
.await?
@@ -170,14 +185,20 @@ async fn http_cache_get(context: &Context, url: &str) -> Result<Option<Response>
170185
}
171186
};
172187

173-
let expires = http_url_cache_expires(url, mimetype.as_deref());
188+
let (expires, _stale) = http_url_cache_timestamps(url, mimetype.as_deref());
174189
let response = Response {
175190
blob,
176191
mimetype,
177192
encoding,
178193
};
179194

180-
// Update expiration timestamp.
195+
// Update expiration timestamp
196+
// to prevent deletion of the file still in use.
197+
//
198+
// We do not update stale timestamp here
199+
// as we have not revalidated the response.
200+
// Stale timestamp is updated only
201+
// when the URL is sucessfully fetched.
181202
context
182203
.sql
183204
.execute(
@@ -186,7 +207,7 @@ async fn http_cache_get(context: &Context, url: &str) -> Result<Option<Response>
186207
)
187208
.await?;
188209

189-
Ok(Some(response))
210+
Ok(Some((response, is_stale)))
190211
}
191212

192213
/// Removes expired cache entries.
@@ -205,14 +226,10 @@ pub(crate) async fn http_cache_cleanup(context: &Context) -> Result<()> {
205226
Ok(())
206227
}
207228

208-
/// Retrieves the binary contents of URL using HTTP GET request.
209-
pub async fn read_url_blob(context: &Context, original_url: &str) -> Result<Response> {
210-
if let Some(response) = http_cache_get(context, original_url).await? {
211-
info!(context, "Returning {original_url:?} from cache.");
212-
return Ok(response);
213-
}
214-
215-
info!(context, "Not found {original_url:?} in cache.");
229+
/// Fetches URL and updates the cache.
230+
///
231+
/// URL is fetched regardless of whether there is an existing result in the cache.
232+
async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
216233
let mut url = original_url.to_string();
217234

218235
// Follow up to 10 http-redirects
@@ -273,6 +290,31 @@ pub async fn read_url_blob(context: &Context, original_url: &str) -> Result<Resp
273290
Err(anyhow!("Followed 10 redirections"))
274291
}
275292

293+
/// Retrieves the binary contents of URL using HTTP GET request.
294+
pub async fn read_url_blob(context: &Context, url: &str) -> Result<Response> {
295+
if let Some((response, is_stale)) = http_cache_get(context, url).await? {
296+
info!(context, "Returning {url:?} from cache.");
297+
if is_stale {
298+
let context = context.clone();
299+
let url = url.to_string();
300+
tokio::spawn(async move {
301+
// Fetch URL in background to update the cache.
302+
info!(context, "Fetching stale {url:?} in background.");
303+
if let Err(err) = fetch_url(&context, &url).await {
304+
warn!(context, "Failed to revalidate {url:?}: {err:#}.");
305+
}
306+
});
307+
}
308+
309+
// Return stale result.
310+
return Ok(response);
311+
}
312+
313+
info!(context, "Not found {url:?} in cache, fetching.");
314+
let response = fetch_url(context, url).await?;
315+
Ok(response)
316+
}
317+
276318
/// Sends an empty POST request to the URL.
277319
///
278320
/// Returns response text and whether request was successful or not.
@@ -401,51 +443,55 @@ mod tests {
401443
assert_eq!(http_cache_get(t, xdc_pixel_url).await?, None);
402444
assert_eq!(
403445
http_cache_get(t, "https://webxdc.org/").await?,
404-
Some(html_response.clone())
446+
Some((html_response.clone(), false))
405447
);
406448

407449
http_cache_put(t, xdc_editor_url, &xdc_response).await?;
408450
http_cache_put(t, xdc_pixel_url, &xdc_response).await?;
409451
assert_eq!(
410452
http_cache_get(t, xdc_editor_url).await?,
411-
Some(xdc_response.clone())
453+
Some((xdc_response.clone(), false))
412454
);
413455
assert_eq!(
414456
http_cache_get(t, xdc_pixel_url).await?,
415-
Some(xdc_response.clone())
457+
Some((xdc_response.clone(), false))
416458
);
417459

418460
assert_eq!(
419461
http_cache_get(t, "https://webxdc.org/").await?,
420-
Some(html_response.clone())
462+
Some((html_response.clone(), false))
421463
);
422464

423-
// HTML expires after 1 hour, but .xdc does not.
465+
// HTML is stale after 1 hour, but .xdc is not.
424466
SystemTime::shift(Duration::from_secs(3600 + 100));
425-
assert_eq!(http_cache_get(t, "https://webxdc.org/").await?, None);
467+
assert_eq!(
468+
http_cache_get(t, "https://webxdc.org/").await?,
469+
Some((html_response.clone(), true))
470+
);
426471
assert_eq!(
427472
http_cache_get(t, xdc_editor_url).await?,
428-
Some(xdc_response.clone())
473+
Some((xdc_response.clone(), false))
429474
);
430475

431-
// Expired cache entry can be renewed
476+
// Stale cache entry can be renewed
432477
// even before housekeeping removes old one.
433478
http_cache_put(t, "https://webxdc.org/", &html_response).await?;
434479
assert_eq!(
435480
http_cache_get(t, "https://webxdc.org/").await?,
436-
Some(html_response.clone())
481+
Some((html_response.clone(), false))
437482
);
438483

439484
// 35 days later pixel .xdc expires because we did not request it for 35 days and 1 hour.
440485
// But editor is still there because we did not request it for just 35 days.
486+
// We have not renewed the editor however, so it becomes stale.
441487
SystemTime::shift(Duration::from_secs(3600 * 24 * 35 - 100));
442488

443489
// Run housekeeping to test that it does not delete the blob too early.
444490
housekeeping(t).await?;
445491

446492
assert_eq!(
447493
http_cache_get(t, xdc_editor_url).await?,
448-
Some(xdc_response.clone())
494+
Some((xdc_response.clone(), true))
449495
);
450496
assert_eq!(http_cache_get(t, xdc_pixel_url).await?, None);
451497

src/sql/migrations.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,24 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
11031103
.await?;
11041104
}
11051105

1106+
inc_and_check(&mut migration_version, 126)?;
1107+
if dbversion < migration_version {
1108+
// Recreate http_cache table with new `stale` column.
1109+
sql.execute_migration(
1110+
"DROP TABLE http_cache;
1111+
CREATE TABLE http_cache (
1112+
url TEXT PRIMARY KEY,
1113+
expires INTEGER NOT NULL, -- When the cache entry is considered expired, timestamp in seconds.
1114+
stale INTEGER NOT NULL, -- When the cache entry is considered stale, timestamp in seconds.
1115+
blobname TEXT NOT NULL,
1116+
mimetype TEXT NOT NULL DEFAULT '', -- MIME type extracted from Content-Type header.
1117+
encoding TEXT NOT NULL DEFAULT '' -- Encoding from Content-Type header.
1118+
) STRICT",
1119+
migration_version,
1120+
)
1121+
.await?;
1122+
}
1123+
11061124
let new_version = sql
11071125
.get_raw_config_int(VERSION_CFG)
11081126
.await?

0 commit comments

Comments
 (0)