Skip to content

Commit ce2b881

Browse files
committed
Add cache_stale_duration_ms configuration option
Introduces a new configuration option `cache_stale_duration_ms` which allows users to control how long files are cached before their freshness is checked. This provides more fine-grained control over caching behavior, especially in production environments.
1 parent 7bb11c1 commit ce2b881

4 files changed

Lines changed: 55 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
- fix: `sqlpage.variables()` now does not return json objects with duplicate keys when post, get and set variables of the same name are present. The semantics of the returned values remains the same (precedence: set > post > get).
99
- add support for some duckdb-specific (like `select {'a': 1, 'b': 2}`), and oracle-specific syntax dynamically when connected through odbc.
1010
- better oidc support. Single-sign-on now works with sites:
11-
- using a non-default `site_prefix`
12-
- hosted behind an ssl-terminating reverse proxy
11+
- using a non-default `site_prefix`
12+
- hosted behind an ssl-terminating reverse proxy
13+
- New config option: `cache_stale_duration_ms` to control the duration for which cached sql files are considered fresh.
1314

1415
## 0.41.0 (2025-12-28)
1516

configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Here are the available configuration options and their default values:
3939
| `https_certificate_cache_dir` | ./sqlpage/https | A writeable directory where to cache the certificates, so that SQLPage can serve https traffic immediately when it restarts. |
4040
| `https_acme_directory_url` | https://acme-v02.api.letsencrypt.org/directory | The URL of the ACME directory to use when requesting a certificate. |
4141
| `environment` | development | The environment in which SQLPage is running. Can be either `development` or `production`. In `production` mode, SQLPage will hide error messages and stack traces from the user, and will cache sql files in memory to avoid reloading them from disk. |
42+
| `cache_stale_duration_ms` | 1000 (prod), 0 (dev) | The duration in milliseconds that a file can be cached before its freshness is checked against the filesystem. Defaults to 1000ms (1 second) in production and 0ms in development. |
4243
| `content_security_policy` | `script-src 'self' 'nonce-{NONCE}'` | The [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to set in the HTTP headers. If you get CSP errors in the browser console, you can set this to the empty string to disable CSP. If you want a custom CSP that contains a nonce, include the `'nonce-{NONCE}'` directive in your configuration string and it will be populated with a random value per request. |
4344
| `system_root_ca_certificates` | false | Whether to use the system root CA certificates to validate SSL certificates when making http requests with `sqlpage.fetch`. If set to false, SQLPage will use its own set of root CA certificates. If the `SSL_CERT_FILE` or `SSL_CERT_DIR` environment variables are set, they will be used instead of the system root CA certificates. |
4445
| `max_recursion_depth` | 10 | Maximum depth of recursion allowed in the `run_sql` function. Maximum value is 255. |

src/app_config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,9 +305,22 @@ pub struct AppConfig {
305305

306306
#[serde(default = "default_markdown_allow_dangerous_protocol")]
307307
pub markdown_allow_dangerous_protocol: bool,
308+
309+
pub cache_stale_duration_ms: Option<u64>,
308310
}
309311

310312
impl AppConfig {
313+
#[must_use]
314+
pub fn cache_stale_duration_ms(&self) -> u64 {
315+
self.cache_stale_duration_ms.unwrap_or_else(|| {
316+
if self.environment.is_prod() {
317+
1000
318+
} else {
319+
0
320+
}
321+
})
322+
}
323+
311324
#[must_use]
312325
pub fn listen_on(&self) -> SocketAddr {
313326
let mut addr = self.listen_on.unwrap_or_else(|| {

src/file_cache.rs

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@ use std::sync::Arc;
1515
use std::time::SystemTime;
1616
use tokio::sync::RwLock;
1717

18-
/// The maximum time in milliseconds that a file can be cached before its freshness is checked
19-
/// (in production mode)
20-
const MAX_STALE_CACHE_MS: u64 = 150;
21-
2218
#[derive(Default)]
2319
struct Cached<T> {
2420
last_checked_at: AtomicU64,
@@ -35,34 +31,32 @@ impl<T> Cached<T> {
3531
s
3632
}
3733
fn last_check_time(&self) -> DateTime<Utc> {
38-
self.last_checked_at
39-
.load(Acquire)
40-
.saturating_mul(MAX_STALE_CACHE_MS)
41-
.try_into()
42-
.ok()
43-
.and_then(|millis| Utc.timestamp_millis_opt(millis).single())
34+
let millis = self.last_checked_at.load(Acquire);
35+
Utc.timestamp_millis_opt(millis as i64)
36+
.single()
4437
.expect("file timestamp out of bound")
4538
}
4639
fn update_check_time(&self) {
47-
self.last_checked_at.store(Self::elapsed(), Release);
40+
self.last_checked_at.store(Self::now_millis(), Release);
4841
}
49-
fn elapsed() -> u64 {
50-
let timestamp_millis = (SystemTime::now().duration_since(SystemTime::UNIX_EPOCH))
42+
fn now_millis() -> u64 {
43+
SystemTime::now()
44+
.duration_since(SystemTime::UNIX_EPOCH)
5145
.expect("invalid duration")
52-
.as_millis();
53-
let elapsed_intervals = timestamp_millis / u128::from(MAX_STALE_CACHE_MS);
54-
u64::try_from(elapsed_intervals).expect("invalid date")
46+
.as_millis()
47+
.try_into()
48+
.expect("invalid date")
5549
}
56-
fn needs_check(&self) -> bool {
50+
fn needs_check(&self, stale_cache_duration_ms: u64) -> bool {
5751
self.last_checked_at
5852
.load(Acquire)
59-
.saturating_add(MAX_STALE_CACHE_MS)
60-
< Self::elapsed()
53+
.saturating_add(stale_cache_duration_ms)
54+
< Self::now_millis()
6155
}
6256
/// Creates a new cached entry with the same content but a new check time set to now
6357
fn make_fresh(&self) -> Self {
6458
Self {
65-
last_checked_at: AtomicU64::from(Self::elapsed()),
59+
last_checked_at: AtomicU64::from(Self::now_millis()),
6660
content: Arc::clone(&self.content),
6761
}
6862
}
@@ -126,7 +120,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
126120
) -> anyhow::Result<Arc<T>> {
127121
log::trace!("Attempting to get from cache {}", path.display());
128122
if let Some(cached) = self.cache.read().await.get(path) {
129-
if app_state.config.environment.is_prod() && !cached.needs_check() {
123+
if !cached.needs_check(app_state.config.cache_stale_duration_ms()) {
130124
log::trace!(
131125
"Cache answer without filesystem lookup for {}",
132126
path.display()
@@ -223,3 +217,26 @@ pub trait AsyncFromStrWithState: Sized {
223217
source_path: &Path,
224218
) -> anyhow::Result<Self>;
225219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::*;
224+
225+
#[tokio::test]
226+
async fn test_cache_duration() {
227+
let cached = Cached::new(());
228+
assert!(
229+
!cached.needs_check(1000),
230+
"Should not need check immediately after creation"
231+
);
232+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
233+
assert!(
234+
!cached.needs_check(1000),
235+
"Should not need check before duration expires"
236+
);
237+
assert!(
238+
cached.needs_check(1),
239+
"Should need check after duration expires"
240+
);
241+
}
242+
}

0 commit comments

Comments
 (0)