epicweb-dev / cachified

🤑 wrap virtually everything that can store by key to act as cache with ttl/max-age, stale-while-validate, parallel fetch protection and type-safety support
MIT License
916 stars 26 forks source link

Add support for "soft purge" #46

Closed kentcdodds closed 1 year ago

kentcdodds commented 1 year ago

More details here: https://developer.fastly.com/reference/api/purging/#soft-vs-hard-purge

I'm writing a blog post about caching and here's how I implement it in my simple example:

async function getEventAttendeeCount(eventId: string) {
    const event = await getEvent(eventId)
    return event.attendees.length
}

const attendeeCountCache = {}
type CacheOptions = { ttl?: number; swr?: number }
async function updateEventAttendeeCountCache(
    eventId: string,
    { ttl = 1000 * 60 * 60 * 24, swr = 1000 * 60 * 60 },
) {
    attendeeCountCache[eventId] = {
        value: await getEventAttendeeCount(eventId),
        createdTime: Date.now(),
        ttl,
        swr,
    }
}

async function softPurgeEventAttendeeCountCache(eventId: string) {
    if (!attendeeCountCache[eventId]) return

    attendeeCountCache[eventId] = {
        ...attendeeCountCache[eventId],
        ttl: 0,
        swr:
            attendeeCountCache[eventId].ttl +
            attendeeCountCache[eventId].createdTime,
    }
}

async function getEventAttendeeCountCached(
    eventId: string,
    { forceFresh, ...cacheOptions }: CacheOptions & { forceFresh?: boolean } = {},
) {
    if (forceFresh) {
        await updateEventAttendeeCountCache(eventId, cacheOptions)
    }
    const cacheEntry = attendeeCountCache[eventId]
    if (!cacheEntry || cacheEntry.createdTime + cacheEntry.ttl < Date.now()) {
        const expiredTime = cacheEntry.createdTime + cacheEntry.ttl
        const serveStale = expiredTime + cacheEntry.swr > Date.now()
        if (serveStale) {
            // fire and forget (update in the background)
            void updateEventAttendeeCountCache(eventId, cacheOptions)
        } else {
            // wait for update
            await updateEventAttendeeCountCache(eventId, cacheOptions)
        }
    }

    return attendeeCountCache[eventId].value
}

So, all we do is update the ttl and swr values to ensure the ttl marks this cached value as expired, but also keep the swr time intact so the next call gets the cached value instantly, but also kicks off an update in the background.

This has the benefit of not immediately putting pressure on our server to update all cached values at once and instead can get them updated over time. Pretty slick.

Xiphe commented 1 year ago

Hey Kent, thanks for the suggestion I really like this.

I'd implement this to have an API something like:

import LRUCache from 'lru-cache';
import { purge } from 'cachified';

const cache = new LRUCache({ max: 1000 });

const entryExisted: boolean = await purge(cache, { key: 'my-key-1', soft: true });

Implementation wise I'd not make the swr shorter then whats currently set. (still setting ttl to 0)

const nextSwr = Math.max(entry.ttl + entry.createdTime, entry.swr);
kentcdodds commented 1 year ago

Sounds good 👍 Thanks!

kentcdodds commented 1 year ago

Thinking about this a bit more:

const nextSwr = Math.max(entry.ttl + entry.createdTime, entry.swr);

Shouldn't that be:

const nextSwr = entry.ttl + entry.createdTime + entry.swr;

It's making my head just thinking about it, but we want swr to expire at the same time as before and if we set the ttl to 0 then we'd need the swr to be set to all of those summed up right?

Xiphe commented 1 year ago

Yeah, I'm sure we mean the same thing. I'm not sure from the top of my head how the calculation is implemented, but I'll make sure the swr time is not being decreased when an entry is purged.

Xiphe commented 1 year ago

I'll be quite occupied in the next days, so I'll probably start working on this in a week or so.

kentcdodds commented 1 year ago

No rush on my end 😁

kentcdodds commented 1 year ago

👏

Xiphe commented 1 year ago

💜 Released with v3.4.1 Docs are here: https://github.com/Xiphe/cachified#soft-purging-entries