Skip to content

Commit abe6f3d

Browse files
nyurikclaude
andcommitted
feat(mbtiles)!: add a fetched timestamp to both cache layouts
Both cache schemas gain a `fetched INTEGER` column (Unix-epoch seconds, right before `expires`) recording when the tile was downloaded/added/ last refreshed - the input for HTTP Age computation and staleness heuristics. - CacheEntryMeta/CachedTile gain a `fetched` field (before `expires`); CacheEntryMeta::new is now (fetched, expires, etag). - update_cached_meta bumps it on 304 revalidation along with expires/etag. - Cache-to-cache copies (any layout direction) preserve it; bulk copies from non-cache sources leave it NULL rather than stamping copy time, keeping identical copy runs byte-identical (existing CLI golden outputs did not change). - Schema detection now expects the six shared columns + one layout column. BREAKING CHANGE: cache schema DDL and `CacheEntryMeta::new` signature changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 22e71c6 commit abe6f3d

12 files changed

Lines changed: 122 additions & 97 deletions

docs/content/mbtiles-schema.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ In our next semver major, we plan to switch this default and produce `tiles_shal
6868

6969
## cache
7070

71-
The `cache` schemas store extra cache metadata (`expires` and `etag`) alongside each tile, so a file can serve as a persistent web-tile cache.
71+
The `cache` schemas store extra cache metadata alongside each tile - `fetched` (when the tile was downloaded/added/last refreshed), `expires`, and `etag` - so a file can serve as a persistent web-tile cache.
7272
Two layouts exist, mirroring the `flat` vs `normalized` split of the regular schemas.
73-
Both center on a `tile_cache` table holding the tile Z,X,Y coordinates and the cache metadata, and both create a spec-compatible `tiles` view so the file can still be read by any standard MBTiles reader (the `expires`/`etag` columns are simply invisible to it).
73+
Both center on a `tile_cache` table holding the tile Z,X,Y coordinates and the cache metadata, and both create a spec-compatible `tiles` view so the file can still be read by any standard MBTiles reader (the extra columns are simply invisible to it).
7474

7575
### cache-flat
7676

@@ -95,8 +95,8 @@ This is the recommended default for web-tile caches, where identical (e.g. empty
9595
The `mbtiles` tool treats both cache layouts as first-class schemas, with a few deliberate restrictions:
9696

9797
* `summary`, `validate`, `meta-*`, and serving the file with `martin` all work.
98-
* `copy` **from** a cache file to any schema works (reading via the `tiles` view); the per-tile `expires`/`etag` values are dropped, since standard schemas cannot store them.
99-
* `copy` **into** a cache file works from any schema (including `martin-cp --mbtiles-type cache-flat|cache-normalized`); the copied entries get `NULL` `expires`/`etag` (never expire). Copies between cache files - including across the two layouts - preserve `expires`/`etag`.
98+
* `copy` **from** a cache file to any schema works (reading via the `tiles` view); the per-tile `fetched`/`expires`/`etag` values are dropped, since standard schemas cannot store them.
99+
* `copy` **into** a cache file works from any schema (including `martin-cp --mbtiles-type cache-flat|cache-normalized`); the copied entries get `NULL` `fetched`/`expires`/`etag` (unknown fetch time, never expire; identical copy runs stay byte-identical). Copies between cache files - including across the two layouts - preserve all cache metadata.
100100
* `diff`, `apply-patch`, and bin-diff **into or onto** a cache file are rejected: the `NOT NULL` blob storage joined through the `tiles` view cannot represent the `NULL` "deleted tile" markers a diff needs. A cache file *can* be the compared-against or patch-source side (it is read through the view).
101101
* `cache-purge <file> [--max-size <MB>]` removes expired entries (and optionally evicts soonest-expiring entries until the file fits the size budget), then reclaims free pages via `PRAGMA incremental_vacuum`.
102102

mbtiles/sql/init-cache-flat.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ CREATE TABLE tile_cache (
22
zoom_level INTEGER NOT NULL,
33
tile_column INTEGER NOT NULL,
44
tile_row INTEGER NOT NULL,
5+
fetched INTEGER,
56
expires INTEGER,
67
etag TEXT,
78
tile_data BLOB NOT NULL,

mbtiles/sql/init-cache-normalized.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CREATE TABLE tile_cache (
77
zoom_level INTEGER NOT NULL,
88
tile_column INTEGER NOT NULL,
99
tile_row INTEGER NOT NULL,
10+
fetched INTEGER,
1011
expires INTEGER,
1112
etag TEXT,
1213
tile_id INTEGER NOT NULL,

mbtiles/src/cache.rs

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@
22
//!
33
//! This is a **non-standard** schema (not part of the `MBTiles` specification) built on
44
//! top of the same `SQLite` file format. It stores tiles together with cache metadata
5-
//! (`expires` and `etag`) and de-duplicates identical tile blobs, and it is intended to
5+
//! (`fetched`, `expires`, and `etag`) and de-duplicates identical tile blobs, and it is intended to
66
//! be embedded by other systems that need a simple on-disk tile cache.
77
//!
88
//! # Schemas
99
//!
1010
//! Two layouts are supported, chosen via [`CacheSchema`]. Both center on a `tile_cache`
11-
//! table storing tile coordinates with `expires`/`etag` metadata, and both expose a
11+
//! table storing tile coordinates with `fetched`/`expires`/`etag` metadata, and both expose a
1212
//! spec-compatible `tiles` view so the file can still be opened by any standard
13-
//! `MBTiles` reader (the `expires`/`etag` columns are simply invisible to it).
13+
//! `MBTiles` reader (the extra cache columns are simply invisible to it).
1414
//!
15-
//! - [`CacheSchema::Flat`]: `tile_cache(zoom_level, tile_column, tile_row, expires,
16-
//! etag, tile_data)` - the blob is stored inline. Simple and fast, best when few
17-
//! tiles share content.
15+
//! - [`CacheSchema::Flat`]: `tile_cache(zoom_level, tile_column, tile_row, fetched,
16+
//! expires, etag, tile_data)` - the blob is stored inline. Simple and fast, best when
17+
//! few tiles share content.
1818
//! - [`CacheSchema::Normalized`]: `tile_cache(zoom_level, tile_column, tile_row,
19-
//! expires, etag, tile_id)` (`WITHOUT ROWID`) plus `cache_data(tile_id INTEGER
19+
//! fetched, expires, etag, tile_id)` (`WITHOUT ROWID`) plus `cache_data(tile_id INTEGER
2020
//! PRIMARY KEY, tile_data BLOB)`. `tile_id` is the
2121
//! [xxh3-64](https://github.com/Cyan4973/xxHash) hash of `tile_data`, stored as an
2222
//! `INTEGER PRIMARY KEY` so it aliases the rowid (single B-tree, no secondary index).
@@ -65,6 +65,9 @@ pub(crate) const MAX_KEY_PROBES: u32 = 1024;
6565
pub struct CachedTile {
6666
/// The tile blob.
6767
pub data: Vec<u8>,
68+
/// Unix-epoch (seconds) time the tile was downloaded/added/last refreshed, or `None`
69+
/// if unknown (e.g. the entry was bulk-imported with `mbtiles copy`).
70+
pub fetched: Option<i64>,
6871
/// Unix-epoch (seconds) expiration time, or `None` if the entry never expires.
6972
///
7073
/// The value is returned exactly as stored; the cache does **not** filter out expired
@@ -78,20 +81,26 @@ pub struct CachedTile {
7881
/// Cache metadata attached to a tile when writing it with [`Mbtiles::set_cached`].
7982
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
8083
pub struct CacheEntryMeta<'a> {
84+
/// Unix-epoch (seconds) time the tile was downloaded/added/last refreshed, or `None`
85+
/// if unknown.
86+
pub fetched: Option<i64>,
8187
/// Unix-epoch (seconds) expiration time, or `None` for an entry that never expires.
8288
pub expires: Option<i64>,
8389
/// Upstream validator (e.g. an HTTP `ETag`), or `None`.
8490
pub etag: Option<&'a str>,
8591
}
8692

8793
impl<'a> CacheEntryMeta<'a> {
88-
/// Create cache metadata with both an `expires` (Unix-epoch seconds) and an `etag`.
94+
/// Create cache metadata with a `fetched` and an `expires` time (both Unix-epoch
95+
/// seconds) and an `etag`.
8996
///
90-
/// For entries missing one or both, construct the struct directly (its fields are
91-
/// public) or use [`CacheEntryMeta::default`] for "never expires, no etag".
97+
/// For entries missing some of these, construct the struct directly (its fields are
98+
/// public) or use [`CacheEntryMeta::default`] for "unknown fetch time, never expires,
99+
/// no etag".
92100
#[must_use]
93-
pub fn new(expires: i64, etag: &'a str) -> Self {
101+
pub fn new(fetched: i64, expires: i64, etag: &'a str) -> Self {
94102
Self {
103+
fetched: Some(fetched),
95104
expires: Some(expires),
96105
etag: Some(etag),
97106
}
@@ -145,7 +154,7 @@ impl Mbtiles {
145154

146155
/// Look up a cached tile by its XYZ coordinates.
147156
///
148-
/// Returns the tile together with its `expires`/`etag` metadata, or `None` if there is
157+
/// Returns the tile together with its `fetched`/`expires`/`etag` metadata, or `None` if there is
149158
/// no entry at the given coordinates. Expired entries are still returned (with their
150159
/// stored `expires`) so the caller can decide whether to serve stale, revalidate via
151160
/// `etag`, or refetch.
@@ -162,12 +171,12 @@ impl Mbtiles {
162171
{
163172
let sql = match schema {
164173
CacheSchema::Flat => {
165-
"SELECT tile_data, expires, etag
174+
"SELECT tile_data, fetched, expires, etag
166175
FROM tile_cache
167176
WHERE zoom_level = ?1 AND tile_column = ?2 AND tile_row = ?3"
168177
}
169178
CacheSchema::Normalized => {
170-
"SELECT d.tile_data, c.expires, c.etag
179+
"SELECT d.tile_data, c.fetched, c.expires, c.etag
171180
FROM tile_cache c
172181
JOIN cache_data d ON d.tile_id = c.tile_id
173182
WHERE c.zoom_level = ?1 AND c.tile_column = ?2 AND c.tile_row = ?3"
@@ -182,12 +191,13 @@ impl Mbtiles {
182191

183192
Ok(row.map(|row| CachedTile {
184193
data: row.get(0),
185-
expires: row.get(1),
186-
etag: row.get(2),
194+
fetched: row.get(1),
195+
expires: row.get(2),
196+
etag: row.get(3),
187197
}))
188198
}
189199

190-
/// Insert or replace a cached tile, with its [`CacheEntryMeta`] (`expires`/`etag`).
200+
/// Insert or replace a cached tile, with its [`CacheEntryMeta`] (`fetched`/`expires`/`etag`).
191201
///
192202
/// With [`CacheSchema::Flat`], this is a plain upsert with the blob stored inline.
193203
///
@@ -213,12 +223,13 @@ impl Mbtiles {
213223
// Inline blob: a plain upsert, no de-duplication or key probing involved.
214224
query(
215225
"INSERT OR REPLACE INTO tile_cache
216-
(zoom_level, tile_column, tile_row, expires, etag, tile_data)
217-
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
226+
(zoom_level, tile_column, tile_row, fetched, expires, etag, tile_data)
227+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
218228
)
219229
.bind(z)
220230
.bind(x)
221231
.bind(invert_y_value(z, y))
232+
.bind(meta.fetched)
222233
.bind(meta.expires)
223234
.bind(meta.etag)
224235
.bind(data)
@@ -273,16 +284,18 @@ impl Mbtiles {
273284
};
274285

275286
query(
276-
"INSERT INTO tile_cache (zoom_level, tile_column, tile_row, expires, etag, tile_id)
277-
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
287+
"INSERT INTO tile_cache (zoom_level, tile_column, tile_row, fetched, expires, etag, tile_id)
288+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
278289
ON CONFLICT(zoom_level, tile_column, tile_row)
279-
DO UPDATE SET expires = excluded.expires,
290+
DO UPDATE SET fetched = excluded.fetched,
291+
expires = excluded.expires,
280292
etag = excluded.etag,
281293
tile_id = excluded.tile_id",
282294
)
283295
.bind(z)
284296
.bind(x)
285297
.bind(invert_y_value(z, y))
298+
.bind(meta.fetched)
286299
.bind(meta.expires)
287300
.bind(meta.etag)
288301
.bind(resolved)
@@ -296,8 +309,8 @@ impl Mbtiles {
296309
Ok(())
297310
}
298311

299-
/// Update only the `expires`/`etag` metadata of an existing cache entry, without
300-
/// touching the tile blob.
312+
/// Update only the `fetched`/`expires`/`etag` metadata of an existing cache entry,
313+
/// without touching the tile blob.
301314
///
302315
/// This is the revalidation path: after a conditional refetch (e.g. HTTP
303316
/// `If-None-Match` answered with `304 Not Modified`), the cached bytes are still
@@ -318,12 +331,13 @@ impl Mbtiles {
318331
for<'e> &'e mut T: SqliteExecutor<'e>,
319332
{
320333
let updated = query(
321-
"UPDATE tile_cache SET expires = ?4, etag = ?5
334+
"UPDATE tile_cache SET fetched = ?4, expires = ?5, etag = ?6
322335
WHERE zoom_level = ?1 AND tile_column = ?2 AND tile_row = ?3",
323336
)
324337
.bind(z)
325338
.bind(x)
326339
.bind(invert_y_value(z, y))
340+
.bind(meta.fetched)
327341
.bind(meta.expires)
328342
.bind(meta.etag)
329343
.execute(conn)
@@ -494,7 +508,7 @@ mod tests {
494508
1,
495509
2,
496510
b"hello",
497-
CacheEntryMeta::new(100, "etag-1"),
511+
CacheEntryMeta::new(42, 100, "etag-1"),
498512
)
499513
.await
500514
.unwrap();
@@ -504,6 +518,7 @@ mod tests {
504518
.unwrap()
505519
.unwrap();
506520
assert_eq!(got.data, b"hello");
521+
assert_eq!(got.fetched, Some(42));
507522
assert_eq!(got.expires, Some(100));
508523
assert_eq!(got.etag.as_deref(), Some("etag-1"));
509524

@@ -525,6 +540,7 @@ mod tests {
525540
.unwrap()
526541
.unwrap();
527542
assert_eq!(got.data, b"world");
543+
assert_eq!(got.fetched, None);
528544
assert_eq!(got.expires, None);
529545
assert_eq!(got.etag, None);
530546
}
@@ -679,14 +695,14 @@ mod tests {
679695
let (mbt, mut conn) = cache(schema).await;
680696
let stale = CacheEntryMeta {
681697
expires: Some(50),
682-
etag: None,
698+
..Default::default()
683699
};
684700
mbt.set_cached(&mut conn, schema, 0, 0, 0, b"stale", stale)
685701
.await
686702
.unwrap();
687703
let fresh = CacheEntryMeta {
688704
expires: Some(200),
689-
etag: None,
705+
..Default::default()
690706
};
691707
mbt.set_cached(&mut conn, schema, 1, 0, 0, b"fresh", fresh)
692708
.await
@@ -786,21 +802,21 @@ mod tests {
786802
1,
787803
2,
788804
b"payload",
789-
CacheEntryMeta::new(100, "etag-1"),
805+
CacheEntryMeta::new(42, 100, "etag-1"),
790806
)
791807
.await
792808
.unwrap();
793809

794810
// No entry at these coordinates - the caller must fall back to set_cached.
795-
let missing = CacheEntryMeta::new(1, "x");
811+
let missing = CacheEntryMeta::new(1, 1, "x");
796812
assert!(
797813
!mbt.update_cached_meta(&mut conn, 3, 0, 0, missing)
798814
.await
799815
.unwrap()
800816
);
801817

802818
// Revalidation bumps the metadata in place; the blob stays untouched.
803-
let bumped = CacheEntryMeta::new(500, "etag-2");
819+
let bumped = CacheEntryMeta::new(20, 500, "etag-2");
804820
assert!(
805821
mbt.update_cached_meta(&mut conn, 3, 1, 2, bumped)
806822
.await
@@ -812,6 +828,7 @@ mod tests {
812828
.unwrap()
813829
.unwrap();
814830
assert_eq!(got.data, b"payload");
831+
assert_eq!(got.fetched, Some(20));
815832
assert_eq!(got.expires, Some(500));
816833
assert_eq!(got.etag.as_deref(), Some("etag-2"));
817834
assert_eq!(blob_count(&mut conn, schema).await, 1);
@@ -836,7 +853,7 @@ mod tests {
836853
1,
837854
1,
838855
b"",
839-
CacheEntryMeta::new(60, "miss-etag"),
856+
CacheEntryMeta::new(5, 60, "miss-etag"),
840857
)
841858
.await
842859
.unwrap();
@@ -846,6 +863,7 @@ mod tests {
846863
.unwrap()
847864
.unwrap();
848865
assert!(got.data.is_empty());
866+
assert_eq!(got.fetched, Some(5));
849867
assert_eq!(got.expires, Some(60));
850868
assert_eq!(got.etag.as_deref(), Some("miss-etag"));
851869

@@ -905,7 +923,7 @@ mod tests {
905923
let meta = if i < 80 {
906924
CacheEntryMeta {
907925
expires: Some(i64::from(i)),
908-
etag: None,
926+
..Default::default()
909927
}
910928
} else {
911929
CacheEntryMeta::default()

mbtiles/src/cache_pool.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ use crate::{CacheEntryMeta, CacheSchema, CachedTile, MbtError, Mbtiles, Metadata
3131
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
3232
/// let cache = MbtilesCache::open("cache.mbtiles").await?;
3333
///
34-
/// // Store a fetched tile with its freshness metadata.
35-
/// cache.set_cached(3, 1, 2, b"tile-bytes", CacheEntryMeta::new(1700000000, "etag-1")).await?;
34+
/// // Store a downloaded tile with its fetch time and freshness metadata.
35+
/// cache.set_cached(3, 1, 2, b"tile-bytes", CacheEntryMeta::new(1700000000, 1700003600, "etag-1")).await?;
3636
///
3737
/// // Later: read it back, expired entries included (freshness is the caller's call).
3838
/// if let Some(tile) = cache.get_cached(3, 1, 2).await? {
3939
/// println!("{} bytes, expires {:?}", tile.data.len(), tile.expires);
4040
/// }
4141
///
4242
/// // After an HTTP 304 revalidation, bump the metadata without rewriting the blob.
43-
/// cache.update_cached_meta(3, 1, 2, CacheEntryMeta::new(1700003600, "etag-1")).await?;
43+
/// cache.update_cached_meta(3, 1, 2, CacheEntryMeta::new(1700003600, 1700007200, "etag-1")).await?;
4444
/// # Ok(())
4545
/// # }
4646
/// ```
@@ -121,7 +121,7 @@ impl MbtilesCache {
121121
self.schema
122122
}
123123

124-
/// Look up a cached tile and its `expires`/`etag` metadata.
124+
/// Look up a cached tile and its `fetched`/`expires`/`etag` metadata.
125125
///
126126
/// See [`Mbtiles::get_cached`] for the semantics (expired entries are still returned).
127127
#[hotpath::measure]
@@ -132,7 +132,7 @@ impl MbtilesCache {
132132
.await
133133
}
134134

135-
/// Insert or replace a cached tile with its [`CacheEntryMeta`] (`expires`/`etag`).
135+
/// Insert or replace a cached tile with its [`CacheEntryMeta`] (`fetched`/`expires`/`etag`).
136136
///
137137
/// See [`Mbtiles::set_cached`] for de-duplication and collision behavior.
138138
#[hotpath::measure]
@@ -150,7 +150,7 @@ impl MbtilesCache {
150150
.await
151151
}
152152

153-
/// Update only the `expires`/`etag` metadata of an existing entry (revalidation).
153+
/// Update only the `fetched`/`expires`/`etag` metadata of an existing entry (revalidation).
154154
///
155155
/// Returns `false` if there is no entry at the given coordinates.
156156
/// See [`Mbtiles::update_cached_meta`].
@@ -228,7 +228,7 @@ mod tests {
228228
let cache = MbtilesCache::open_with_schema(&path, schema).await.unwrap();
229229
assert_eq!(cache.schema(), schema);
230230
cache
231-
.set_cached(2, 1, 1, b"tile-a", CacheEntryMeta::new(50, "v1"))
231+
.set_cached(2, 1, 1, b"tile-a", CacheEntryMeta::new(40, 50, "v1"))
232232
.await
233233
.unwrap();
234234
cache
@@ -243,18 +243,20 @@ mod tests {
243243
assert_eq!(cache.schema(), schema);
244244
let a = cache.get_cached(2, 1, 1).await.unwrap().unwrap();
245245
assert_eq!(a.data, b"tile-a");
246+
assert_eq!(a.fetched, Some(40));
246247
assert_eq!(a.expires, Some(50));
247248
assert_eq!(a.etag.as_deref(), Some("v1"));
248249
assert_eq!(cache.get_tile(2, 1, 2).await.unwrap().unwrap(), b"tile-b");
249250

250251
// Revalidate the expiring entry without rewriting its blob.
251252
assert!(
252253
cache
253-
.update_cached_meta(2, 1, 1, CacheEntryMeta::new(75, "v1"))
254+
.update_cached_meta(2, 1, 1, CacheEntryMeta::new(60, 75, "v1"))
254255
.await
255256
.unwrap()
256257
);
257258
let a = cache.get_cached(2, 1, 1).await.unwrap().unwrap();
259+
assert_eq!(a.fetched, Some(60));
258260
assert_eq!(a.expires, Some(75));
259261

260262
// Purge the expired entry; the permanent one survives.

0 commit comments

Comments
 (0)