From 354887e559b13281d8eb63b0e932ea899eca8c80 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 09:45:16 +0000 Subject: [PATCH 1/4] Release file cache locks before metadata checks --- src/file_cache.rs | 240 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 235 insertions(+), 5 deletions(-) diff --git a/src/file_cache.rs b/src/file_cache.rs index 7287e4137..30a773bc0 100644 --- a/src/file_cache.rs +++ b/src/file_cache.rs @@ -65,7 +65,7 @@ impl Cached { } pub struct FileCache { - cache: Arc>>>, + cache: Arc>>>>, /// Files that are loaded at the beginning of the program, /// and used as fallback when there is no match for the request in the file system static_files: HashMap>, @@ -115,7 +115,9 @@ impl FileCache { let path = access.path(); log::trace!("Attempting to get from cache {}", path.display()); - if let Some(cached) = self.cache.read().await.get(path) { + // Keep a snapshot so metadata I/O cannot block cache insertions or evictions. + let cached = self.cache.read().await.get(path).cloned(); + if let Some(cached) = cached { if !cached.needs_check(app_state.config.cache_stale_duration_ms()) { log::trace!( "Cache answer without filesystem lookup for {}", @@ -144,7 +146,6 @@ impl FileCache { ), } } - // Read lock is released log::trace!("Loading and parsing {}", path.display()); let file_contents = app_state .file_system @@ -184,7 +185,10 @@ impl FileCache { Ok(value) => { let new_val = Arc::clone(&value.content); log::trace!("Writing to cache {}", path.display()); - self.cache.write().await.insert(PathBuf::from(path), value); + self.cache + .write() + .await + .insert(PathBuf::from(path), Arc::new(value)); log::trace!("Done writing to cache {}", path.display()); log::trace!("{} loaded in cache", path.display()); Ok(new_val) @@ -217,6 +221,232 @@ pub trait AsyncFromStrWithState: Sized { #[cfg(test)] mod tests { use super::*; + use crate::filesystem::DbFsQueries; + use crate::webserver::Database; + use sqlx::executor::Executor; + use std::time::Duration; + use tempfile::TempDir; + + #[async_trait(?Send)] + impl AsyncFromStrWithState for String { + async fn from_str_with_state( + _app_state: &AppState, + source: &str, + _source_path: &Path, + ) -> anyhow::Result { + anyhow::ensure!(source != "invalid", "invalid cached content"); + Ok(source.to_owned()) + } + } + + async fn state_in(dir: &TempDir, database_files: bool) -> anyhow::Result { + let mut config = crate::app_config::tests::test_config(); + // These tests deliberately use an isolated one-connection pool to control I/O. + config.database_url = "sqlite::memory:".to_owned(); + config.max_database_pool_connections = Some(1); + config.cache_stale_duration_ms = Some(1000); + config.web_root = dir.path().to_owned(); + config.configuration_directory = dir.path().join("sqlpage"); + let db = Database::init(&config).await?; + if database_files { + db.connection + .execute(DbFsQueries::get_create_table_sql(db.info.database_type)) + .await?; + } + AppState::init_with_db(&config, db).await + } + + async fn mark_stale(cache: &FileCache, path: &Path) { + cache.cache.read().await[path] + .last_checked_at + .store(0, Release); + } + + #[actix_web::test] + async fn pending_metadata_check_allows_writes_without_restoring_old_entries() + -> anyhow::Result<()> { + let dir = TempDir::new()?; + let state = state_in(&dir, true).await?; + let path = Path::new("cached.txt"); + let unrelated = Path::new("unrelated.txt"); + for replace in [false, true] { + let cache = FileCache::::new(); + let snapshot = Arc::new(Cached::new("original".to_owned())); + snapshot.last_checked_at.store(0, Release); + cache + .cache + .write() + .await + .insert(path.to_owned(), Arc::clone(&snapshot)); + + // No local file exists: metadata must wait for this database connection. + let connection = state.db.connection.acquire().await?; + let lookup = cache.get(&state, FileAccess::unprivileged(path)?); + tokio::pin!(lookup); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut lookup) + .await + .is_err(), + "metadata lookup should wait for the held connection" + ); + + let replacement = Arc::new(Cached::new("replacement".to_owned())); + let replacement_checked_at = replacement.last_checked_at.load(Acquire); + { + let mut entries = tokio::time::timeout(Duration::from_secs(1), cache.cache.write()) + .await + .expect("pending metadata must not block cache writes"); + entries.insert( + unrelated.to_owned(), + Arc::new(Cached::new("unrelated".to_owned())), + ); + entries.remove(unrelated); + if replace { + entries.insert(path.to_owned(), Arc::clone(&replacement)); + } else { + entries.remove(path); + } + } + + drop(connection); + let result = tokio::time::timeout(Duration::from_secs(1), &mut lookup).await??; + assert_eq!(*result, "original"); + assert!(snapshot.last_checked_at.load(Acquire) > 0); + let entries = cache.cache.read().await; + if replace { + assert!(Arc::ptr_eq(&entries[path], &replacement)); + assert_eq!( + replacement.last_checked_at.load(Acquire), + replacement_checked_at + ); + } else { + assert!( + !entries.contains_key(path), + "eviction must remain effective" + ); + } + } + Ok(()) + } + + #[actix_web::test] + async fn refreshes_changed_files_and_falls_back_to_static_content() -> anyhow::Result<()> { + let dir = TempDir::new()?; + let state = state_in(&dir, false).await?; + let mut cache = FileCache::::new(); + let path = Path::new("file.txt"); + let local_path = dir.path().join(path); + cache.add_static(path.to_owned(), "static".to_owned()); + tokio::fs::write(&local_path, "first").await?; + + let initial = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert_eq!(*initial, "first"); + let unchanged = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert!(Arc::ptr_eq(&initial, &unchanged)); + + tokio::fs::write(&local_path, "changed").await?; + mark_stale(&cache, path).await; + let changed = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert_eq!(*changed, "changed"); + assert!(!Arc::ptr_eq(&initial, &changed)); + + tokio::fs::remove_file(&local_path).await?; + mark_stale(&cache, path).await; + let fallback = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert_eq!(*fallback, "static"); + assert!(Arc::ptr_eq(&fallback, &cache.get_static(path)?)); + Ok(()) + } + + #[actix_web::test] + async fn local_files_take_precedence_over_database_and_static_files() -> anyhow::Result<()> { + let dir = TempDir::new()?; + let state = state_in(&dir, true).await?; + let mut cache = FileCache::::new(); + let path = Path::new("file.txt"); + cache.add_static(path.to_owned(), "static".to_owned()); + sqlx::query::query("INSERT INTO sqlpage_files(path, contents) VALUES (?, ?)") + .bind("file.txt") + .bind(b"database".as_slice()) + .execute(&state.db.connection) + .await?; + let local_path = dir.path().join(path); + tokio::fs::write(&local_path, "local").await?; + + let local = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert_eq!(*local, "local"); + tokio::fs::remove_file(&local_path).await?; + mark_stale(&cache, path).await; + let database = cache.get(&state, FileAccess::unprivileged(path)?).await?; + assert_eq!(*database, "database"); + Ok(()) + } + + #[actix_web::test] + async fn missing_files_evict_entries_and_parse_errors_preserve_them() -> anyhow::Result<()> { + let dir = TempDir::new()?; + let state = state_in(&dir, false).await?; + let cache = FileCache::::new(); + let path = Path::new("file.txt"); + let local_path = dir.path().join(path); + tokio::fs::write(&local_path, "valid").await?; + let initial = cache.get(&state, FileAccess::unprivileged(path)?).await?; + + tokio::fs::write(&local_path, "invalid").await?; + mark_stale(&cache, path).await; + let error = cache + .get(&state, FileAccess::unprivileged(path)?) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "invalid cached content"); + assert!(Arc::ptr_eq( + &cache.cache.read().await[path].content, + &initial + )); + + tokio::fs::remove_file(&local_path).await?; + let error = cache + .get(&state, FileAccess::unprivileged(path)?) + .await + .unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&ErrorWithStatus { + status: StatusCode::NOT_FOUND + }) + ); + assert!(!cache.cache.read().await.contains_key(path)); + Ok(()) + } + + #[cfg(unix)] + #[actix_web::test] + async fn metadata_and_read_errors_do_not_use_static_fallback() -> anyhow::Result<()> { + let dir = TempDir::new()?; + let state = state_in(&dir, false).await?; + let mut cache = FileCache::::new(); + let path = Path::new("file.txt"); + let local_path = dir.path().join(path); + cache.add_static(path.to_owned(), "static".to_owned()); + tokio::fs::write(&local_path, "valid").await?; + cache.get(&state, FileAccess::unprivileged(path)?).await?; + tokio::fs::remove_file(&local_path).await?; + std::os::unix::fs::symlink(path, &local_path)?; + mark_stale(&cache, path).await; + + let error = cache + .get(&state, FileAccess::unprivileged(path)?) + .await + .unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&ErrorWithStatus { + status: StatusCode::INTERNAL_SERVER_ERROR + }) + ); + assert!(!cache.cache.read().await.contains_key(path)); + Ok(()) + } #[tokio::test] async fn test_cache_duration() { @@ -225,7 +455,7 @@ mod tests { !cached.needs_check(1000), "Should not need check immediately after creation" ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; + tokio::time::sleep(Duration::from_millis(10)).await; assert!( !cached.needs_check(1000), "Should not need check before duration expires" From aa965ec9bbfa8dfcb6cbdc8e0883c59d93000b33 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 10:18:16 +0000 Subject: [PATCH 2/4] test: consolidate cache scenarios using the template parser --- src/file_cache.rs | 253 +++++++++++++++++++--------------------------- 1 file changed, 106 insertions(+), 147 deletions(-) diff --git a/src/file_cache.rs b/src/file_cache.rs index 30a773bc0..9f4f0b5aa 100644 --- a/src/file_cache.rs +++ b/src/file_cache.rs @@ -222,31 +222,23 @@ pub trait AsyncFromStrWithState: Sized { mod tests { use super::*; use crate::filesystem::DbFsQueries; + use crate::templates::{SplitTemplate, split_template}; use crate::webserver::Database; + use handlebars::{Template, template::TemplateElement}; use sqlx::executor::Executor; use std::time::Duration; - use tempfile::TempDir; - #[async_trait(?Send)] - impl AsyncFromStrWithState for String { - async fn from_str_with_state( - _app_state: &AppState, - source: &str, - _source_path: &Path, - ) -> anyhow::Result { - anyhow::ensure!(source != "invalid", "invalid cached content"); - Ok(source.to_owned()) - } + fn template(source: &str) -> SplitTemplate { + split_template(Template::compile(source).unwrap()) } - async fn state_in(dir: &TempDir, database_files: bool) -> anyhow::Result { + async fn state_in(dir: &Path, database_files: bool) -> anyhow::Result { let mut config = crate::app_config::tests::test_config(); - // These tests deliberately use an isolated one-connection pool to control I/O. config.database_url = "sqlite::memory:".to_owned(); config.max_database_pool_connections = Some(1); config.cache_stale_duration_ms = Some(1000); - config.web_root = dir.path().to_owned(); - config.configuration_directory = dir.path().join("sqlpage"); + config.web_root = dir.to_owned(); + config.configuration_directory = dir.join("sqlpage"); let db = Database::init(&config).await?; if database_files { db.connection @@ -256,7 +248,7 @@ mod tests { AppState::init_with_db(&config, db).await } - async fn mark_stale(cache: &FileCache, path: &Path) { + async fn mark_stale(cache: &FileCache, path: &Path) { cache.cache.read().await[path] .last_checked_at .store(0, Release); @@ -265,52 +257,40 @@ mod tests { #[actix_web::test] async fn pending_metadata_check_allows_writes_without_restoring_old_entries() -> anyhow::Result<()> { - let dir = TempDir::new()?; - let state = state_in(&dir, true).await?; - let path = Path::new("cached.txt"); - let unrelated = Path::new("unrelated.txt"); + let dir = tempfile::tempdir()?; + let state = state_in(dir.path(), true).await?; + let path = Path::new("cached.handlebars"); for replace in [false, true] { - let cache = FileCache::::new(); - let snapshot = Arc::new(Cached::new("original".to_owned())); + let cache = FileCache::new(); + let snapshot = Arc::new(Cached::new(template("original"))); snapshot.last_checked_at.store(0, Release); cache .cache .write() .await .insert(path.to_owned(), Arc::clone(&snapshot)); - - // No local file exists: metadata must wait for this database connection. + // Missing local metadata must await the held database connection. let connection = state.db.connection.acquire().await?; let lookup = cache.get(&state, FileAccess::unprivileged(path)?); tokio::pin!(lookup); - assert!( - tokio::time::timeout(Duration::from_millis(50), &mut lookup) - .await - .is_err(), - "metadata lookup should wait for the held connection" - ); - - let replacement = Arc::new(Cached::new("replacement".to_owned())); + assert!(futures_util::poll!(&mut lookup).is_pending()); + let replacement = Arc::new(Cached::new(template("replacement"))); let replacement_checked_at = replacement.last_checked_at.load(Acquire); { - let mut entries = tokio::time::timeout(Duration::from_secs(1), cache.cache.write()) - .await - .expect("pending metadata must not block cache writes"); - entries.insert( - unrelated.to_owned(), - Arc::new(Cached::new("unrelated".to_owned())), - ); - entries.remove(unrelated); + let mut entries = + tokio::time::timeout(Duration::from_secs(1), cache.cache.write()).await?; + let unrelated = PathBuf::from("unrelated.handlebars"); + entries.insert(unrelated.clone(), Arc::clone(&replacement)); + entries.remove(&unrelated); if replace { entries.insert(path.to_owned(), Arc::clone(&replacement)); } else { entries.remove(path); } } - drop(connection); let result = tokio::time::timeout(Duration::from_secs(1), &mut lookup).await??; - assert_eq!(*result, "original"); + assert!(Arc::ptr_eq(&result, &snapshot.content)); assert!(snapshot.last_checked_at.load(Acquire) > 0); let entries = cache.cache.read().await; if replace { @@ -330,121 +310,100 @@ mod tests { } #[actix_web::test] - async fn refreshes_changed_files_and_falls_back_to_static_content() -> anyhow::Result<()> { - let dir = TempDir::new()?; - let state = state_in(&dir, false).await?; - let mut cache = FileCache::::new(); - let path = Path::new("file.txt"); + async fn refreshes_local_database_and_static_templates_in_order() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let mut state = state_in(dir.path(), true).await?; + let mut cache = FileCache::new(); + let path = Path::new("file.handlebars"); + let access = FileAccess::unprivileged(path)?; let local_path = dir.path().join(path); - cache.add_static(path.to_owned(), "static".to_owned()); - tokio::fs::write(&local_path, "first").await?; - - let initial = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert_eq!(*initial, "first"); - let unchanged = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert!(Arc::ptr_eq(&initial, &unchanged)); - - tokio::fs::write(&local_path, "changed").await?; - mark_stale(&cache, path).await; - let changed = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert_eq!(*changed, "changed"); - assert!(!Arc::ptr_eq(&initial, &changed)); - - tokio::fs::remove_file(&local_path).await?; - mark_stale(&cache, path).await; - let fallback = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert_eq!(*fallback, "static"); - assert!(Arc::ptr_eq(&fallback, &cache.get_static(path)?)); - Ok(()) - } - - #[actix_web::test] - async fn local_files_take_precedence_over_database_and_static_files() -> anyhow::Result<()> { - let dir = TempDir::new()?; - let state = state_in(&dir, true).await?; - let mut cache = FileCache::::new(); - let path = Path::new("file.txt"); - cache.add_static(path.to_owned(), "static".to_owned()); + cache.add_static(path.to_owned(), template("static")); sqlx::query::query("INSERT INTO sqlpage_files(path, contents) VALUES (?, ?)") - .bind("file.txt") + .bind(path.to_str()) .bind(b"database".as_slice()) .execute(&state.db.connection) .await?; - let local_path = dir.path().join(path); - tokio::fs::write(&local_path, "local").await?; - - let local = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert_eq!(*local, "local"); - tokio::fs::remove_file(&local_path).await?; - mark_stale(&cache, path).await; - let database = cache.get(&state, FileAccess::unprivileged(path)?).await?; - assert_eq!(*database, "database"); - Ok(()) - } - - #[actix_web::test] - async fn missing_files_evict_entries_and_parse_errors_preserve_them() -> anyhow::Result<()> { - let dir = TempDir::new()?; - let state = state_in(&dir, false).await?; - let cache = FileCache::::new(); - let path = Path::new("file.txt"); - let local_path = dir.path().join(path); - tokio::fs::write(&local_path, "valid").await?; - let initial = cache.get(&state, FileAccess::unprivileged(path)?).await?; - - tokio::fs::write(&local_path, "invalid").await?; - mark_stale(&cache, path).await; - let error = cache - .get(&state, FileAccess::unprivileged(path)?) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "invalid cached content"); - assert!(Arc::ptr_eq( - &cache.cache.read().await[path].content, - &initial - )); - - tokio::fs::remove_file(&local_path).await?; - let error = cache - .get(&state, FileAccess::unprivileged(path)?) - .await - .unwrap_err(); - assert_eq!( - error.downcast_ref::(), - Some(&ErrorWithStatus { - status: StatusCode::NOT_FOUND - }) - ); - assert!(!cache.cache.read().await.contains_key(path)); + let mut previous = None; + for source in ["first", "changed", "database", "static"] { + match source { + "database" => tokio::fs::remove_file(&local_path).await?, + "static" => { + state + .db + .connection + .execute("DROP TABLE sqlpage_files") + .await?; + state.file_system = + crate::filesystem::FileSystem::init(dir.path(), &state.db).await; + } + _ => tokio::fs::write(&local_path, source).await?, + } + if previous.is_some() { + mark_stale(&cache, path).await; + } + let loaded = cache.get(&state, access).await?; + if let Some(old) = &previous { + assert!(!Arc::ptr_eq(old, &loaded)); + } + assert_eq!( + loaded.before_list.elements, + [TemplateElement::RawString(source.into())] + ); + assert!(Arc::ptr_eq(&loaded, &cache.get(&state, access).await?)); + previous = Some(loaded); + } + assert!(Arc::ptr_eq(&previous.unwrap(), &cache.get_static(path)?)); Ok(()) } - #[cfg(unix)] #[actix_web::test] - async fn metadata_and_read_errors_do_not_use_static_fallback() -> anyhow::Result<()> { - let dir = TempDir::new()?; - let state = state_in(&dir, false).await?; - let mut cache = FileCache::::new(); - let path = Path::new("file.txt"); + async fn parse_errors_retain_entries_and_io_errors_evict_them() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let state = state_in(dir.path(), false).await?; + let path = Path::new("file.handlebars"); + let access = FileAccess::unprivileged(path)?; let local_path = dir.path().join(path); - cache.add_static(path.to_owned(), "static".to_owned()); - tokio::fs::write(&local_path, "valid").await?; - cache.get(&state, FileAccess::unprivileged(path)?).await?; - tokio::fs::remove_file(&local_path).await?; - std::os::unix::fs::symlink(path, &local_path)?; - mark_stale(&cache, path).await; - - let error = cache - .get(&state, FileAccess::unprivileged(path)?) - .await - .unwrap_err(); - assert_eq!( - error.downcast_ref::(), - Some(&ErrorWithStatus { - status: StatusCode::INTERNAL_SERVER_ERROR - }) - ); - assert!(!cache.cache.read().await.contains_key(path)); + for (failure, status, retain) in [ + ("parse", None, true), + ("missing", Some(StatusCode::NOT_FOUND), false), + ("metadata", Some(StatusCode::INTERNAL_SERVER_ERROR), false), + ] { + let mut cache = FileCache::new(); + if failure != "missing" { + cache.add_static(path.to_owned(), template("static")); + } + tokio::fs::write(&local_path, "valid").await?; + let initial = cache.get(&state, access).await?; + mark_stale(&cache, path).await; + match failure { + "parse" => tokio::fs::write(&local_path, "{{#if missing}}").await?, + "missing" => tokio::fs::remove_file(&local_path).await?, + _ => { + tokio::fs::remove_file(&local_path).await?; + #[cfg(unix)] + std::os::unix::fs::symlink(path, &local_path)?; + #[cfg(not(unix))] + continue; + } + } + let error = cache + .get(&state, access) + .await + .err() + .expect("expected load failure"); + assert_eq!( + error + .downcast_ref::() + .map(|error| error.status), + status + ); + let entries = cache.cache.read().await; + assert_eq!(entries.contains_key(path), retain, "{failure}"); + if retain { + assert!(error.downcast_ref::().is_some()); + assert!(Arc::ptr_eq(&entries[path].content, &initial)); + } + } Ok(()) } From 597f140bbd9357d6ce9818adc89ec6c2b5b45b05 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 7 Oct 2026 11:08:35 +0000 Subject: [PATCH 3/4] Document the cache concurrency performance fix --- CHANGELOG.md | 1 + examples/official-site/performance.sql | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ad050af..ef1e6f298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- Slow checks for changed SQL files or templates no longer delay cache updates for other pages. - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements. - Chart data points can now include a `link`. Clicking a point or its tooltip value opens that URL; a text x value in the tooltip title links to it too. The tooltip remains open while the pointer moves onto the link. ApexCharts was updated to [v7.6.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.6.0) to support this. - Updated sqlx-oldapi to v0.6.57 to fix SQL Server fallback expressions such as `ISNULL($missing, 'default')` truncating defaults or failing for date values when the bound variable is `NULL`. diff --git a/examples/official-site/performance.sql b/examples/official-site/performance.sql index 332f6e3b6..7ef617795 100644 --- a/examples/official-site/performance.sql +++ b/examples/official-site/performance.sql @@ -60,6 +60,10 @@ SQLPage also caches the compiled component templates that are used to generate t Both [built-in components](/documentation.sql) and [custom components](/custom_components.sql) you write yourself are parsed just once, and compiled to an efficient memory representation that can be reused for every request. +SQLPage checks cached SQL files and templates for changes according to +[`cache_stale_duration_ms`](https://github.com/sqlpage/SQLPage/blob/main/configuration.md). +Slow filesystem or database freshness checks do not block cache updates for other files. + ## Processing data as fast as your CPU can go In a traditional web development stack, the code you write in a high-level language has to be interpreted by a runtime From 79c03bebbba36b7ff78404afa2bc480656cc04de Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Fri, 9 Oct 2026 12:46:25 +0000 Subject: [PATCH 4/4] Require clear changelog entries with significant user impact --- AGENTS.md | 9 ++++++++- CHANGELOG.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6448e0b28..08b2ebb97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,14 @@ pattern for the relevant area. - Document other user-visible behavior—SQL syntax extensions, variables, control flow, errors, uploads, rendering, HTTP endpoints, performance, or deployment—in the corresponding official-site SQL page or migration. Follow nearby migrations and keep examples executable and database-portable where possible. -- Update `CHANGELOG.md` for user-visible changes only (new features, bug fixes, breaking changes, deprecations). Keep the entry concise and not too technical, focusing on the impact for users. Don't update the entry for a version that was already released (tagged). +- Include a change in `CHANGELOG.md` only when it has a significant impact on users. Omit internal + refactors, maintenance work, and insignificant performance changes. + Write for nontechnical users who know nothing about SQLPage internals: clearly explain who is + affected, what they will experience, and how that differs from before. Use plain language and keep + entries concise; leave implementation details and internal terminology in the pull request. + Back performance claims with measurements showing a noticeable improvement, and describe the + conditions under which users benefit without implying that every site will become faster. + Don't update the entry for a version that was already released (tagged). ## Validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f158213e..21ef143c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) -- Slow freshness checks for database-backed files no longer stall unrelated cached pages. In a benchmark with a busy database pool, an unrelated request dropped from about 250 ms to under 1 ms. +- For sites that store their pages in a database, checking for page updates no longer makes other pages wait when the database is busy. In a test, another page loaded in under 1 ms instead of about 250 ms. - Loading embedded card content no longer adds duplicate form submission or file validation handlers to existing fields. Fragment initialization also includes the element announcing the fragment. - **Mac users:** the downloadable `sqlpage-macos.tgz` is now code-signed with a Developer ID certificate and notarized by Apple. macOS may still ask you to confirm opening it the first time. The binary can still load third-party ODBC database drivers. - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements.