UNPKG

@paroicms/server-image-cache-engine

Version:

The image variant engine that we use at Paroi.

520 lines 18.5 kB
import { parseTimeWithUnit } from "@paroicms/internal-anywhere-lib"; import { parseSqliteDateTime } from "@paroicms/internal-server-lib"; import { isDef } from "@paroicms/public-anywhere-lib"; import { type } from "arktype"; import { BatchStoredCacheItemRowAT, ImageVariantRowAT, ImageVariantWithBinaryByOwnerRowAT, ImageVariantWithBinaryRowAT, } from "./queries-at.js"; const ExistingImageCacheRowAT = type({ id: "number", "+": "reject", }).pipe((r) => ({ id: String(r.id), })); const InsertedImageCacheRowAT = type({ id: "number", modifiedAt: "string|number|Date", "+": "reject", }).pipe((r) => ({ id: String(r.id), modifiedAt: parseSqliteDateTime(r.modifiedAt), })); const ModifiedAtRowAT = type({ modifiedAt: "string|number|Date", "+": "reject", }).pipe((r) => ({ modifiedAt: parseSqliteDateTime(r.modifiedAt), })); export async function insertIntoImageVariant(ctx, data, owners) { const { cn } = ctx; const mTypeRSize = makeMTypeRSizeKey(data); const existingRow = await cn("PaImageCache") .select("id") .where({ mediaId: data.mediaId, mTypeRSize, }) .first(); let imageCacheId = existingRow ? ExistingImageCacheRowAT.assert(existingRow).id : undefined; let modifiedAt; if (imageCacheId === undefined) { const [inserted] = await cn("PaImageCache") .insert({ resourceVersion: data.resourceVersion, mediaId: data.mediaId, mTypeRSize, mediaType: data.mediaType, rawWidth: data.rawWidth, rawHeight: data.rawHeight, weightB: data.weightB, binaryFile: data.binaryFile, slug: data.slug, modifiedAt: cn.fn.now(), }) .returning(["id", "modifiedAt"]); const newValues = InsertedImageCacheRowAT.assert(inserted); imageCacheId = newValues.id; modifiedAt = newValues.modifiedAt; } else { await cn("PaImageCache") .where({ id: imageCacheId, }) .update({ weightB: data.weightB, binaryFile: data.binaryFile, modifiedAt: cn.fn.now(), }); const row = await cn("PaImageCache") .select("modifiedAt") .where("id", imageCacheId) .first(); if (!row) throw new Error(`missing cache row: ${imageCacheId}`); modifiedAt = ModifiedAtRowAT.assert(row).modifiedAt; } await insertIntoPaImageCacheOwner(ctx, { imageCacheId, owners }); return { imageCacheId, lastModified: modifiedAt.toISOString() }; } export async function insertIntoPaImageCacheOwner(ctx, input) { const { cn } = ctx; const { imageCacheId, owners } = input; if (owners.length === 0) return; for (const { ownerHandle, rawResizeR } of owners) { await cn("PaImageCacheOwner") .insert({ imageCacheId, ownerHandle, rawResizeR, }) .onConflict(["imageCacheId", "ownerHandle", "rawResizeR"]) .ignore(); } } export async function fetchImageVariantWithBinary(ctx, input) { const { mediaId } = input; const row = await ctx .cn("PaImageCache as v") .select([ "v.id", "v.resourceVersion", "v.rawWidth", "v.rawHeight", "v.mediaType", "v.weightB", "v.binaryFile", "v.modifiedAt", "v.slug", ]) .where({ "v.mediaId": mediaId, "v.mTypeRSize": makeMTypeRSizeKey(input), }) .first(); if (!row) return; const { binaryFile, ...rest } = row; const { imageCacheId, variant, modifiedAt, weightB } = ImageVariantWithBinaryRowAT.assert({ ...rest, mediaId, }); return { id: imageCacheId, variant: { ...variant, weightB, binaryFile: binaryFile, lastModified: modifiedAt.toISOString(), }, }; } export async function fetchImageVariantWithBinaryByOwner(ctx, input) { const { mediaId, mediaType, ownerHandle, rawResizeR } = input; const row = await ctx .cn("PaImageCache as v") .innerJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id") .select([ "v.id", "v.mediaId", "v.resourceVersion", "v.rawWidth", "v.rawHeight", "v.mediaType", "v.weightB", "v.binaryFile", "v.modifiedAt", "v.slug", ]) .where({ "v.mediaId": mediaId, "v.mediaType": mediaType, "o.ownerHandle": ownerHandle, "o.rawResizeR": rawResizeR, }) .first(); if (!row) return; const { binaryFile, ...rest } = row; const { imageCacheId: id, modifiedAt, variant, weightB, } = ImageVariantWithBinaryByOwnerRowAT.assert(rest); return { id, variant: { ...variant, weightB, binaryFile: binaryFile, lastModified: modifiedAt.toISOString(), }, }; } export async function deleteVariants(ctx, options) { const { cn } = ctx; if ("mediaIds" in options) { if (ctx.debugMode) { await logBeforeDeleteCacheImages(ctx, options.mediaIds); } await cn("PaImageCache").whereIn("mediaId", options.mediaIds).delete(); } else { if (ctx.debugMode) { await logBeforeDeleteCacheImages(ctx, [options.mediaId]); } await cn("PaImageCache").where("mediaId", options.mediaId).delete(); } } /** * @returns the list of cache ownerHandles that were deleted */ export async function deleteVariantsByDependencies(ctx, options) { const { cn, logger } = ctx; const count = await cn("PaImageCacheOwner as o") .whereIn("o.ownerHandle", options.ownerHandles) .delete(); logger.debug(`[image-cache] Deleted ${count} cache owner(s) for [${options.ownerHandles.join("] [")}]`); await deleteOrphanVariants(ctx); } const OrphanVariantRowAT = type({ id: "number", mediaId: "string", "+": "reject", }).pipe((r) => ({ id: String(r.id), mediaId: r.mediaId, })); /** * Delete the images that have no ownerHandle and that have not been used recently. * @returns the number of cache identifiers that were deleted */ export async function deleteOrphanVariants(ctx) { const { cn, timeToIdle } = ctx; if (timeToIdle === undefined) return 0; const days = parseTimeWithUnit(timeToIdle).delay; const rows = await cn("PaImageCache") .select("id", "mediaId") .whereNotExists(cn("PaImageCacheOwner as k").select(1).whereRaw("k.imageCacheId = PaImageCache.id")) .andWhereRaw(`date(modifiedAt) < date(current_timestamp, '-${days} day')`); if (rows.length === 0) return 0; const orphanRows = rows.map((row) => OrphanVariantRowAT.assert(row)); // DEBUG: Log orphaned images before deletion (at warning level for production visibility) await debugLogOrphanedImages(ctx); if (ctx.debugMode) { await logBeforeDeleteCacheImages(ctx, orphanRows.map((row) => row.mediaId)); } const result = await cn("PaImageCache") .whereIn("id", orphanRows.map((row) => row.id)) .delete(); ctx.logger.debug(`[image-cache] Deleted ${result} orphaned image cache variants (older than ${days} days)`); return result; } const PreviousOwnedRowAT = type({ imageCacheId: "number", rawResizeR: "string", "+": "reject", }).pipe((r) => ({ imageCacheId: String(r.imageCacheId), rawResizeR: r.rawResizeR, })); export async function associateOwnerToImages(ctx, { ownerHandle, cacheImages, isHandleReusable, }) { const { cn, logger } = ctx; await cn.transaction(async (tx) => { const prevRows = await tx("PaImageCacheOwner") .select(["imageCacheId", "rawResizeR"]) .where({ ownerHandle }); const prevOwnedItems = new Map(prevRows.map((row) => { const item = PreviousOwnedRowAT.assert(row); return [makeCacheImageKey(item), item]; })); const newCacheImages = new Map(cacheImages.map((i) => [makeCacheImageKey(i), i.rawResizeR])); // Insert new items const toInsert = cacheImages.filter((item) => !prevOwnedItems.has(makeCacheImageKey(item))); if (toInsert.length > 0) { await tx("PaImageCacheOwner").insert(toInsert.map(({ imageCacheId, rawResizeR }) => ({ imageCacheId, ownerHandle, rawResizeR, }))); } // Delete obsolete items if (!isHandleReusable) { const toDelete = Array.from(prevOwnedItems.entries()) .filter(([key]) => !newCacheImages.has(key)) .map(([_, item]) => item); if (toDelete.length > 0) { // DEBUG: Log details about owner associations being deleted await debugLogOwnerDissociation(ctx, tx, { ownerHandle, toDelete, newCacheImages: Array.from(newCacheImages.keys()), prevOwnedItems: Array.from(prevOwnedItems.keys()), }); const count = await tx("PaImageCacheOwner") .where({ ownerHandle }) .whereIn(["imageCacheId", "rawResizeR"], toDelete.map((item) => [item.imageCacheId, item.rawResizeR])) .delete(); logger.debug(`[image-cache] Deleted ${count} cache owner(s) for [${ownerHandle}]`); } } }); } function makeCacheImageKey({ imageCacheId, rawResizeR, }) { return `${imageCacheId}:${rawResizeR}`; } export async function fetchStoredCacheItems(ctx, ownerHandle) { const { cn } = ctx; const query = cn("PaImageCache as v") .select([ "v.id", "v.mediaId", "v.mediaType", "v.rawWidth", "v.rawHeight", "v.resourceVersion", "v.slug", ]) .join("PaImageCacheOwner as o", "o.imageCacheId", "v.id") .where("o.ownerHandle", ownerHandle); const rows = await query; return rows.map((row) => { const { imageCacheId, variant } = ImageVariantRowAT.assert(row); return { imageCacheId, variant, alreadyOwned: true, }; }); } export async function fetchBatchOfStoredCacheItems(ctx, groups, alreadyOwnedBy) { if (groups.length === 0) return []; const { cn } = ctx; const query = cn("PaImageCache as v") .select([ "v.id", "v.mediaId", "v.mediaType", "v.rawWidth", "v.rawHeight", "v.resourceVersion", "v.slug", "o.ownerHandle", ]) .leftJoin("PaImageCacheOwner as o", { "o.imageCacheId": "v.id", "o.ownerHandle": cn.raw("?", [alreadyOwnedBy]), }); query.where((qb) => { for (const { mediaIds, requestedVariants } of groups) { if (mediaIds.length === 0 || requestedVariants.length === 0) continue; qb.orWhere((subQb) => { subQb.whereIn("v.mediaId", mediaIds).whereIn("v.mTypeRSize", requestedVariants.map((v) => makeMTypeRSizeKey(v))); }); } }); const rows = await query; return rows.map((row) => { const { imageCacheId, variant, ownerHandle } = BatchStoredCacheItemRowAT.assert(row); return { imageCacheId, variant, alreadyOwned: isDef(ownerHandle), }; }); } export async function clearAll({ cn, logger }) { await cn("PaImageCacheOwner").delete(); await cn("PaImageCache").delete(); logger.debug("[image-cache] Cleared all image cache entries"); } const UsedRawSizeRowAT = type({ rawResizeR: "string", rawWidth: "number", rawHeight: "number", "+": "reject", }); export function makeMTypeRSizeKey(image) { return `${image.rawWidth}x${image.rawHeight}:${image.mediaType}`; } export async function fetchUsedRawSizesByMedia(ctx, mediaId) { const { cn } = ctx; const rows = await cn("PaImageCache as v") .innerJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id") .select(["o.rawResizeR", "v.rawWidth", "v.rawHeight"]) .where("v.mediaId", mediaId) .orderBy("v.rawWidth", "asc") .orderBy("v.rawHeight", "asc") .distinct(); return rows.map((row) => { const { rawResizeR, rawWidth, rawHeight } = UsedRawSizeRowAT.assert(row); return { rawResizeR: rawResizeR, rawSizeName: `${rawWidth}x${rawHeight}`, }; }); } const CacheEntryWithOwnerRowAT = type({ id: "number", mediaId: "string", resourceVersion: "string", mTypeRSize: "string", modifiedAt: "string|number|Date", ownerHandle: "string|null", rawResizeR: "string|null", "+": "reject", }).pipe((r) => ({ id: String(r.id), mediaId: r.mediaId, resourceVersion: r.resourceVersion, mTypeRSize: r.mTypeRSize, modifiedAt: parseSqliteDateTime(r.modifiedAt), ownerHandle: r.ownerHandle ?? undefined, rawResizeR: r.rawResizeR ?? undefined, })); async function logBeforeDeleteCacheImages(ctx, mediaIds) { if (!ctx.debugMode) return; if (mediaIds.length === 0) return; const { cn } = ctx; const rows = await cn("PaImageCache as v") .select([ "v.id", "v.mediaId", "v.resourceVersion", "v.mTypeRSize", "v.modifiedAt", "o.ownerHandle", "o.rawResizeR", ]) .leftJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id") .whereIn("v.mediaId", mediaIds) .orderBy("v.id"); const validatedRows = rows.map((row) => CacheEntryWithOwnerRowAT.assert(row)); const entriesMap = new Map(); for (const row of validatedRows) { const { id, mediaId, resourceVersion, mTypeRSize, modifiedAt, ownerHandle, rawResizeR } = row; let entry = entriesMap.get(id); if (!entry) { entry = { id, mediaId, resourceVersion, mTypeRSize, modifiedAt, owners: [], }; entriesMap.set(id, entry); } if (ownerHandle && rawResizeR) { entry.owners.push({ ownerHandle, rawResizeR }); } } for (const entry of entriesMap.values()) { const ownersStr = entry.owners.length > 0 ? entry.owners.map((o) => `(${o.ownerHandle} ${o.rawResizeR})`).join(", ") : "no owners"; ctx.logger.debug(`[image-cache] DELETE image cache [${entry.id}][${entry.mediaId}][${entry.resourceVersion}] ${entry.mTypeRSize} - ${entry.modifiedAt.toISOString()} - owned by: ${ownersStr}`); } } // ============================================================================ // DEBUG FUNCTIONS - Easy to remove later // These functions help diagnose the image orphaning bug (#231). // They log at WARNING level to ensure visibility in production logs. // ============================================================================ const DebugImageInfoRowAT = type({ id: "number", mediaId: "string", mTypeRSize: "string", modifiedAt: "string|number|Date", "+": "reject", }).pipe((r) => ({ id: String(r.id), mediaId: r.mediaId, mTypeRSize: r.mTypeRSize, modifiedAt: parseSqliteDateTime(r.modifiedAt), })); /** * DEBUG: Log when owner associations are being deleted. * This helps diagnose the orphaning bug where images lose their owners unexpectedly. */ async function debugLogOwnerDissociation(ctx, tx, data) { const { logger } = ctx; const { ownerHandle, toDelete, newCacheImages, prevOwnedItems } = data; // Get image details for the items being deleted const imageCacheIds = toDelete.map((item) => item.imageCacheId); const rows = await tx("PaImageCache") .select(["id", "mediaId", "mTypeRSize", "modifiedAt"]) .whereIn("id", imageCacheIds); const imageInfoMap = new Map(rows.map((row) => { const info = DebugImageInfoRowAT.assert(row); return [info.id, info]; })); // Build a detailed log message const deletedDetails = toDelete.map((item) => { const info = imageInfoMap.get(item.imageCacheId); if (info) { return `[${item.imageCacheId}] ${info.mediaId} ${info.mTypeRSize} (${item.rawResizeR})`; } return `[${item.imageCacheId}] (${item.rawResizeR})`; }); logger.warn(`[image-cache][DEBUG] Owner dissociation for [${ownerHandle}]: ` + `removing ${toDelete.length} association(s). ` + `Previous: [${prevOwnedItems.join(", ")}]. ` + `New: [${newCacheImages.join(", ")}]. ` + `Deleted: ${deletedDetails.join("; ")}`); } /** * DEBUG: Log when images become orphaned (no owners left). * Call this before deleting orphan variants to see what's being cleaned up. */ export async function debugLogOrphanedImages(ctx) { const { cn, logger, timeToIdle } = ctx; if (timeToIdle === undefined) return; const days = parseTimeWithUnit(timeToIdle).delay; // Find orphaned images that will be deleted const rows = await cn("PaImageCache") .select(["id", "mediaId", "mTypeRSize", "modifiedAt"]) .whereNotExists(cn("PaImageCacheOwner as k").select(1).whereRaw("k.imageCacheId = PaImageCache.id")) .andWhereRaw(`date(modifiedAt) < date(current_timestamp, '-${days} day')`); if (rows.length === 0) return; const orphanedImages = rows.map((row) => DebugImageInfoRowAT.assert(row)); // Group by mediaId for more readable output const byMediaId = new Map(); for (const img of orphanedImages) { const list = byMediaId.get(img.mediaId) ?? []; list.push(img); byMediaId.set(img.mediaId, list); } const summary = Array.from(byMediaId.entries()) .map(([mediaId, images]) => { const sizes = images.map((img) => img.mTypeRSize).join(", "); return `${mediaId}: [${sizes}]`; }) .join("; "); logger.warn(`[image-cache][DEBUG] About to delete ${orphanedImages.length} orphaned image(s) ` + `(no owners, older than ${days} days): ${summary}`); } //# sourceMappingURL=queries.js.map