mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)
Blob cache previously leaked forever: clearTripData omitted it, entries had no trip discriminator, and there was no size/count bound, so file blobs survived trip eviction and could starve the map-tile cache for quota. - BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a backfill upgrade (legacy rows -> tripId -1, bytes from blob.size) - clearTripData purges the trip's blobs in-transaction - enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB - tripSyncManager threads tripId/bytes into puts and enforces the budget
This commit is contained in:
@@ -47,7 +47,15 @@ export interface SyncMeta {
|
||||
export interface BlobCacheEntry {
|
||||
/** Relative URL, e.g. "/api/files/42/download" */
|
||||
url: string;
|
||||
/**
|
||||
* Trip this blob belongs to, so it is evicted together with the trip in
|
||||
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
||||
*/
|
||||
tripId: number;
|
||||
blob: Blob;
|
||||
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
||||
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
||||
bytes: number;
|
||||
mime: string;
|
||||
cachedAt: number;
|
||||
}
|
||||
@@ -121,6 +129,17 @@ class TrekOfflineDb extends Dexie {
|
||||
tags: 'id',
|
||||
categories: 'id',
|
||||
});
|
||||
|
||||
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
||||
// bounded by an LRU budget (see enforceBlobBudget).
|
||||
this.version(3).stores({
|
||||
blobCache: 'url, cachedAt, tripId',
|
||||
}).upgrade(async (tx) => {
|
||||
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
||||
if (row.tripId == null) row.tripId = -1;
|
||||
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
||||
* documents never starve the map-tile cache (sized at MAX_TILES in
|
||||
* tilePrefetcher.ts) for the origin's storage quota.
|
||||
*/
|
||||
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
||||
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/**
|
||||
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
||||
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
||||
* is a reasonable proxy for access for write-once document blobs.
|
||||
*/
|
||||
export async function enforceBlobBudget(
|
||||
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
||||
maxBytes = BLOB_CACHE_MAX_BYTES,
|
||||
): Promise<void> {
|
||||
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
||||
let count = entries.length;
|
||||
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
||||
if (count <= maxCount && totalBytes <= maxBytes) return;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
for (const e of entries) {
|
||||
if (count <= maxCount && totalBytes <= maxBytes) break;
|
||||
toDelete.push(e.url);
|
||||
totalBytes -= e.bytes ?? 0;
|
||||
count -= 1;
|
||||
}
|
||||
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
@@ -263,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.tripMembers,
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
@@ -276,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
);
|
||||
// Remove the trip row itself outside the transaction since it's a separate table
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
upsertCategories,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
enforceBlobBudget,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
@@ -109,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||
if (!resp.ok) continue
|
||||
const blob = await resp.blob()
|
||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
||||
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
|
||||
cached++
|
||||
} catch {
|
||||
// Network failure — skip this file, will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the blob cache within its size/count budget after adding new files.
|
||||
if (cached > 0) await enforceBlobBudget().catch(() => {})
|
||||
|
||||
// Update filesCachedCount in syncMeta
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
|
||||
Reference in New Issue
Block a user